An app for logging board climbs
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add calendar

+181
+128
www/routes/stats.ts
··· 141 141 return `hsl(${hue}, 65%, 50%)` 142 142 } 143 143 144 + function dayKey(d: Date): string { 145 + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${ 146 + String(d.getDate()).padStart(2, '0') 147 + }` 148 + } 149 + 150 + function attemptsByDay(): Map<string, number> { 151 + const m = new Map<string, number>() 152 + for (const session of Object.values(app.sessions)) { 153 + for (const a of session.attempts) { 154 + const key = dayKey(new Date(a.timestamp)) 155 + m.set(key, (m.get(key) ?? 0) + a.attempts) 156 + } 157 + } 158 + return m 159 + } 160 + 161 + interface CalendarCell { 162 + date: Date 163 + key: string 164 + count: number 165 + level: 0 | 1 | 2 | 3 | 4 166 + future: boolean 167 + } 168 + 169 + function buildCalendar( 170 + weeks: number, 171 + ): { weeks: CalendarCell[][]; monthLabels: (string | null)[] } { 172 + const today = new Date() 173 + today.setHours(0, 0, 0, 0) 174 + const startOfCurrentWeek = new Date(today) 175 + startOfCurrentWeek.setDate(today.getDate() - today.getDay()) 176 + const start = new Date(startOfCurrentWeek) 177 + start.setDate(start.getDate() - (weeks - 1) * 7) 178 + 179 + const activity = attemptsByDay() 180 + let max = 0 181 + for (const v of activity.values()) if (v > max) max = v 182 + const step = Math.max(1, Math.ceil(max / 4)) 183 + const levelFor = (count: number): 0 | 1 | 2 | 3 | 4 => { 184 + if (count <= 0) return 0 185 + return Math.min(4, Math.ceil(count / step)) as 1 | 2 | 3 | 4 186 + } 187 + 188 + const weekCols: CalendarCell[][] = [] 189 + const monthLabels: (string | null)[] = [] 190 + let prevMonth = -1 191 + for (let w = 0; w < weeks; w++) { 192 + const col: CalendarCell[] = [] 193 + for (let d = 0; d < 7; d++) { 194 + const date = new Date(start) 195 + date.setDate(start.getDate() + w * 7 + d) 196 + const key = dayKey(date) 197 + const count = activity.get(key) ?? 0 198 + col.push({ 199 + date, 200 + key, 201 + count, 202 + level: levelFor(count), 203 + future: date.getTime() > today.getTime(), 204 + }) 205 + } 206 + weekCols.push(col) 207 + const firstOfWeek = col[0].date 208 + const month = firstOfWeek.getMonth() 209 + monthLabels.push(month !== prevMonth ? monthName(month) : null) 210 + prevMonth = month 211 + } 212 + return { weeks: weekCols, monthLabels } 213 + } 214 + 215 + function monthName(m: number): string { 216 + return new Date(2000, m, 1).toLocaleDateString('en-US', { month: 'short' }) 217 + } 218 + 219 + function cellTitle(cell: CalendarCell): string { 220 + const date = cell.date.toLocaleDateString('en-US', { 221 + month: 'short', 222 + day: 'numeric', 223 + year: 'numeric', 224 + }) 225 + if (cell.future) return date 226 + const noun = cell.count === 1 ? 'attempt' : 'attempts' 227 + return `${date} · ${cell.count} ${noun}` 228 + } 229 + 144 230 export class StatsPage extends LitElement { 145 231 private range: Range = 'month' 146 232 private gradeScale: GradeScale = GradeScale.enum.french ··· 283 369 ) 284 370 285 371 return html` 372 + <section class="stats-section"> 373 + <h2 class="stats-section-title">Activity (past year)</h2> 374 + ${this.renderCalendar()} 375 + </section> 376 + 286 377 <ui-button-group id="stats-range"> 287 378 ${unsafeHTML(this.rangeHtml())} 288 379 </ui-button-group> ··· 324 415 `} 325 416 </div> 326 417 </section> 418 + ` 419 + } 420 + 421 + private renderCalendar(): TemplateResult { 422 + const { weeks, monthLabels } = buildCalendar(53) 423 + return html` 424 + <div class="cal"> 425 + <div 426 + class="cal-months" 427 + style="grid-template-columns: repeat(${weeks 428 + .length}, 1fr);" 429 + > 430 + ${monthLabels.map((l) => 431 + html` 432 + <span>${l ?? ''}</span> 433 + ` 434 + )} 435 + </div> 436 + <div 437 + class="cal-grid" 438 + style="grid-template-columns: repeat(${weeks.length}, 1fr);" 439 + > 440 + ${weeks.map((col) => 441 + col.map((cell) => 442 + html` 443 + <div 444 + class="cal-cell" 445 + data-level="${cell.level}" 446 + ?data-future="${cell.future}" 447 + title="${cellTitle(cell)}" 448 + > 449 + </div> 450 + ` 451 + ) 452 + )} 453 + </div> 454 + </div> 327 455 ` 328 456 } 329 457
+53
www/static/theme.css
··· 349 349 margin: 0; 350 350 } 351 351 352 + .cal { 353 + display: flex; 354 + flex-direction: column; 355 + gap: var(--s1); 356 + overflow-x: auto; 357 + } 358 + 359 + .cal-months, 360 + .cal-grid { 361 + display: grid; 362 + gap: 2px; 363 + min-width: 560px; 364 + } 365 + 366 + .cal-months { 367 + font-size: var(--f7); 368 + opacity: 0.6; 369 + } 370 + 371 + .cal-months span { 372 + white-space: nowrap; 373 + } 374 + 375 + .cal-grid { 376 + grid-template-rows: repeat(7, 10px); 377 + grid-auto-flow: column; 378 + } 379 + 380 + .cal-cell { 381 + border-radius: 2px; 382 + background: hsla(var(--bodyH), var(--bodyS), var(--bodyL), 0.08); 383 + } 384 + 385 + .cal-cell[data-level='1'] { 386 + background: hsla(var(--successH), var(--successS), var(--successL), 0.25); 387 + } 388 + 389 + .cal-cell[data-level='2'] { 390 + background: hsla(var(--successH), var(--successS), var(--successL), 0.5); 391 + } 392 + 393 + .cal-cell[data-level='3'] { 394 + background: hsla(var(--successH), var(--successS), var(--successL), 0.75); 395 + } 396 + 397 + .cal-cell[data-level='4'] { 398 + background: hsl(var(--successH), var(--successS), var(--successL)); 399 + } 400 + 401 + .cal-cell[data-future] { 402 + visibility: hidden; 403 + } 404 + 352 405 .active-session-card { 353 406 display: flex; 354 407 flex-direction: column;