An app for logging board climbs
0
fork

Configure Feed

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

feat: add logbook

+767
+4
www/index.html
··· 36 36 <img src="/static/icons/home.svg" alt="" aria-hidden="true"> 37 37 <span>Home</span> 38 38 </a> 39 + <a href="/library" data-route> 40 + <img src="/static/icons/library.svg" alt="" aria-hidden="true"> 41 + <span>Library</span> 42 + </a> 39 43 <a href="/stopwatch" data-route> 40 44 <img src="/static/icons/clock.svg" alt="" aria-hidden="true"> 41 45 <span>Stopwatch</span>
+9
www/index.ts
··· 1 1 import { Router } from '@bpev/civility' 2 2 import './utils/updates.ts' 3 3 import './routes/home.ts' 4 + import './routes/library.ts' 4 5 import { formatStopwatchShort, globalStopwatch } from './routes/stopwatch.ts' 5 6 import './routes/settings.ts' 6 7 ··· 27 28 this.renderPage('home-page') 28 29 this.updateActiveNavLink('/') 29 30 // home-page sets its own title via connectedCallback 31 + }, 32 + }) 33 + 34 + this.router.on('/library', { 35 + on: () => { 36 + this.renderPage('library-page') 37 + this.updateActiveNavLink('/library') 38 + this.updatePageTitle('Library') 30 39 }, 31 40 }) 32 41
+165
www/routes/home.ts
··· 1 1 import Hammer from 'hammerjs' 2 + import { getClimbLog, logSession } from '../utils/logbook.ts' 2 3 3 4 interface Benchmark { 4 5 id: number ··· 163 164 private mbType = 0 164 165 private gradeScale: 'french' | 'v' = 'french' 165 166 private hammer: HammerManager | null = null 167 + private currentDialogClimb: Benchmark | null = null 168 + private dialogRating: number | null = null 166 169 167 170 private gradeLabel(grade: number): string { 168 171 const table = this.gradeScale === 'v' ? GRADE_V : GRADE_FRENCH ··· 239 242 > 240 243 <div id="bm-detail-header" class="bm-dh-header"></div> 241 244 <div id="bm-detail-content" class="bm-detail-body"></div> 245 + <div id="bm-detail-dialog" class="lb-overlay" hidden> 246 + <div class="lb-dialog" id="lb-dialog-box"></div> 247 + </div> 242 248 </div> 243 249 ` 244 250 } ··· 305 311 this.renderList() 306 312 } 307 313 }) 314 + 315 + this.querySelector('#bm-detail-dialog')?.addEventListener('click', (e) => { 316 + if (e.target === this.querySelector('#bm-detail-dialog')) { 317 + this.hideLogDialog() 318 + } 319 + }) 320 + 321 + this.querySelector('#lb-dialog-box')?.addEventListener( 322 + 'click', 323 + (e) => this.handleDialogClick(e), 324 + ) 308 325 } 309 326 310 327 private applyFilters(): void { ··· 400 417 } 401 418 402 419 private closeDetail(): void { 420 + this.hideLogDialog() 403 421 this.querySelector('#bm-detail')?.classList.remove('open') 404 422 this.querySelector('#bm-detail')?.setAttribute('aria-hidden', 'true') 405 423 this.detailIndex = -1 ··· 479 497 <img src="/static/icons/youtube.svg" alt="" aria-hidden="true" width="18" height="18"> 480 498 Search Beta Videos 481 499 </a> 500 + 501 + <div class="lb-log-section" id="lb-log-section"> 502 + ${this.logSectionHtml(climb)} 503 + </div> 482 504 ` 505 + 506 + contentEl.querySelector('#lb-log-btn')?.addEventListener( 507 + 'click', 508 + () => this.showLogDialog(climb), 509 + ) 483 510 484 511 contentEl.scrollTop = 0 485 512 ··· 487 514 const canvas = this.querySelector<HTMLCanvasElement>('#bm-canvas') 488 515 if (canvas) drawClimb(canvas, climb, config.rows) 489 516 }) 517 + } 518 + 519 + private logSectionHtml(climb: Benchmark): string { 520 + const entry = getClimbLog(climb.id) 521 + if (!entry) { 522 + return `<button class="lb-log-btn" id="lb-log-btn">Log Attempt</button>` 523 + } 524 + const stars = entry.rating 525 + ? '★'.repeat(entry.rating) + '☆'.repeat(3 - entry.rating) 526 + : '' 527 + return ` 528 + <div class="lb-log-summary"> 529 + ${ 530 + entry.sent 531 + ? '<span class="lb-badge lb-badge--sent">Sent</span>' 532 + : '<span class="lb-badge lb-badge--project">Project</span>' 533 + } 534 + <span>${entry.totalAttempts} attempt${ 535 + entry.totalAttempts === 1 ? '' : 's' 536 + }</span> 537 + ${stars ? `<span class="lb-entry-stars">${stars}</span>` : ''} 538 + </div> 539 + <button class="lb-log-btn" id="lb-log-btn">Log Another Session</button> 540 + ` 541 + } 542 + 543 + private refreshLogSection(climb: Benchmark): void { 544 + const section = this.querySelector<HTMLElement>('#lb-log-section') 545 + if (!section) return 546 + section.innerHTML = this.logSectionHtml(climb) 547 + section.querySelector('#lb-log-btn')?.addEventListener( 548 + 'click', 549 + () => this.showLogDialog(climb), 550 + ) 551 + } 552 + 553 + private showLogDialog(climb: Benchmark): void { 554 + this.currentDialogClimb = climb 555 + this.dialogRating = null 556 + const box = this.querySelector<HTMLElement>('#lb-dialog-box') 557 + const overlay = this.querySelector<HTMLElement>('#bm-detail-dialog') 558 + if (!box || !overlay) return 559 + 560 + box.innerHTML = ` 561 + <div class="lb-dialog-header"> 562 + <span class="lb-dialog-title">Log Session</span> 563 + <button class="lb-dialog-close" id="lb-dialog-close" aria-label="Close">✕</button> 564 + </div> 565 + <div class="lb-field"> 566 + <label class="lb-field-label" for="lb-attempts">Attempts</label> 567 + <div class="lb-counter"> 568 + <button class="lb-counter-btn" id="lb-dec" type="button">−</button> 569 + <input type="number" id="lb-attempts" class="lb-counter-input" value="1" min="1" max="999"> 570 + <button class="lb-counter-btn" id="lb-inc" type="button">+</button> 571 + </div> 572 + </div> 573 + <div class="lb-field lb-field--row"> 574 + <label class="lb-field-label" for="lb-sent">Sent?</label> 575 + <input type="checkbox" id="lb-sent" class="lb-checkbox"> 576 + </div> 577 + <div class="lb-field"> 578 + <label class="lb-field-label">Rating</label> 579 + <div class="lb-star-row"> 580 + <button class="lb-star-btn" data-star="1" type="button">★</button> 581 + <button class="lb-star-btn" data-star="2" type="button">★</button> 582 + <button class="lb-star-btn" data-star="3" type="button">★</button> 583 + </div> 584 + </div> 585 + <button class="lb-submit-btn" id="lb-submit" type="button">Save Session</button> 586 + ` 587 + overlay.hidden = false 588 + } 589 + 590 + private hideLogDialog(): void { 591 + const overlay = this.querySelector<HTMLElement>('#bm-detail-dialog') 592 + if (overlay) overlay.hidden = true 593 + this.currentDialogClimb = null 594 + this.dialogRating = null 595 + } 596 + 597 + private handleDialogClick(e: Event): void { 598 + const target = e.target as HTMLElement 599 + const box = this.querySelector<HTMLElement>('#lb-dialog-box') 600 + if (!box) return 601 + 602 + if (target.closest('#lb-dialog-close')) { 603 + this.hideLogDialog() 604 + return 605 + } 606 + 607 + const attemptsInput = box.querySelector<HTMLInputElement>('#lb-attempts') 608 + 609 + if (target.closest('#lb-dec') && attemptsInput) { 610 + attemptsInput.value = String( 611 + Math.max(1, parseInt(attemptsInput.value) - 1), 612 + ) 613 + return 614 + } 615 + 616 + if (target.closest('#lb-inc') && attemptsInput) { 617 + attemptsInput.value = String( 618 + Math.min(999, parseInt(attemptsInput.value) + 1), 619 + ) 620 + return 621 + } 622 + 623 + const star = target.closest<HTMLElement>('.lb-star-btn') 624 + if (star) { 625 + const val = parseInt(star.dataset.star ?? '0') 626 + this.dialogRating = this.dialogRating === val ? null : val 627 + box.querySelectorAll<HTMLButtonElement>('.lb-star-btn').forEach((b) => { 628 + const bVal = parseInt(b.dataset.star ?? '0') 629 + b.classList.toggle( 630 + 'lb-star-btn--active', 631 + this.dialogRating !== null && bVal <= this.dialogRating, 632 + ) 633 + }) 634 + return 635 + } 636 + 637 + if (target.closest('#lb-submit')) { 638 + if (!this.currentDialogClimb) return 639 + const attempts = Math.max(1, parseInt(attemptsInput?.value ?? '1')) 640 + const sent = box.querySelector<HTMLInputElement>('#lb-sent')?.checked ?? 641 + false 642 + logSession( 643 + { 644 + id: this.currentDialogClimb.id, 645 + mbType: this.currentDialogClimb.mb_type, 646 + name: this.currentDialogClimb.name, 647 + grade: this.currentDialogClimb.grade, 648 + setter: this.currentDialogClimb.setter, 649 + }, 650 + { attempts, sent, rating: this.dialogRating }, 651 + ) 652 + this.hideLogDialog() 653 + this.refreshLogSection(this.currentDialogClimb) 654 + } 490 655 } 491 656 } 492 657
+178
www/routes/library.ts
··· 1 + import { type ClimbLogEntry, loadLogbook } from '../utils/logbook.ts' 2 + 3 + const GRADE_FRENCH: Record<number, string> = { 4 + 0: '5+', 5 + 1: '6A', 6 + 2: '6A+', 7 + 3: '6B', 8 + 4: '6B+', 9 + 5: '6C', 10 + 6: '6C+', 11 + 7: '7A', 12 + 8: '7A+', 13 + 9: '7B', 14 + 10: '7B+', 15 + 11: '7C', 16 + 12: '7C+', 17 + 13: '8A', 18 + 14: '8A+', 19 + 15: '8B', 20 + 16: '8B+', 21 + } 22 + 23 + const GRADE_V: Record<number, string> = { 24 + 0: 'V1', 25 + 1: 'V2', 26 + 2: 'V3', 27 + 3: 'V4', 28 + 4: 'V4', 29 + 5: 'V5', 30 + 6: 'V5', 31 + 7: 'V6', 32 + 8: 'V7', 33 + 9: 'V8', 34 + 10: 'V8', 35 + 11: 'V9', 36 + 12: 'V10', 37 + 13: 'V11', 38 + 14: 'V12', 39 + 15: 'V13', 40 + 16: 'V14', 41 + } 42 + 43 + function escapeHtml(str: string): string { 44 + return str 45 + .replace(/&/g, '&amp;') 46 + .replace(/</g, '&lt;') 47 + .replace(/>/g, '&gt;') 48 + .replace(/"/g, '&quot;') 49 + } 50 + 51 + function gradeLabel(grade: number, scale: 'french' | 'v'): string { 52 + return (scale === 'v' ? GRADE_V : GRADE_FRENCH)[grade] ?? '?' 53 + } 54 + 55 + function formatDate(iso: string): string { 56 + return new Date(iso).toLocaleDateString('en-US', { 57 + month: 'short', 58 + day: 'numeric', 59 + year: 'numeric', 60 + }) 61 + } 62 + 63 + function starsHtml(rating: number | null): string { 64 + if (rating === null) return '' 65 + return '★'.repeat(rating) + '☆'.repeat(3 - rating) 66 + } 67 + 68 + type Filter = 'all' | 'sent' | 'unsent' 69 + 70 + export class LibraryPage extends HTMLElement { 71 + private filter: Filter = 'all' 72 + private gradeScale: 'french' | 'v' = 'french' 73 + 74 + connectedCallback() { 75 + this.gradeScale = (localStorage.getItem('grade_scale') as 'french' | 'v') ?? 76 + 'french' 77 + this.innerHTML = ` 78 + <div class="lb-page"> 79 + <div class="lb-filter-bar" id="lb-filter-bar"> 80 + ${this.filterBarHtml()} 81 + </div> 82 + <div id="lb-list"></div> 83 + </div> 84 + ` 85 + this.renderList() 86 + this.addEventListener('click', this.handleClick) 87 + } 88 + 89 + disconnectedCallback() { 90 + this.removeEventListener('click', this.handleClick) 91 + } 92 + 93 + private handleClick = (e: Event) => { 94 + const btn = (e.target as HTMLElement).closest<HTMLElement>('[data-filter]') 95 + if (!btn) return 96 + this.filter = btn.dataset.filter as Filter 97 + const filterBar = this.querySelector('#lb-filter-bar') 98 + if (filterBar) filterBar.innerHTML = this.filterBarHtml() 99 + this.renderList() 100 + } 101 + 102 + private sortedEntries(): ClimbLogEntry[] { 103 + return Object.values(loadLogbook()).sort( 104 + (a, b) => 105 + new Date(b.lastAttempted).getTime() - 106 + new Date(a.lastAttempted).getTime(), 107 + ) 108 + } 109 + 110 + private filterBarHtml(): string { 111 + const opts: { value: Filter; label: string }[] = [ 112 + { value: 'all', label: 'All' }, 113 + { value: 'sent', label: 'Completed' }, 114 + { value: 'unsent', label: 'Not Completed' }, 115 + ] 116 + return opts 117 + .map( 118 + (o) => 119 + `<button class="lb-filter-btn${ 120 + this.filter === o.value ? ' lb-filter-btn--active' : '' 121 + }" data-filter="${o.value}">${o.label}</button>`, 122 + ) 123 + .join('') 124 + } 125 + 126 + private renderList(): void { 127 + const listEl = this.querySelector('#lb-list') 128 + if (!listEl) return 129 + 130 + const all = this.sortedEntries() 131 + const filtered = all.filter((e) => { 132 + if (this.filter === 'sent') return e.sent 133 + if (this.filter === 'unsent') return !e.sent 134 + return true 135 + }) 136 + 137 + if (all.length === 0) { 138 + listEl.innerHTML = 139 + `<p class="lb-empty">No climbs logged yet.<br>Log attempts from a climb's detail view.</p>` 140 + return 141 + } 142 + 143 + if (filtered.length === 0) { 144 + listEl.innerHTML = `<p class="lb-empty">No climbs match this filter.</p>` 145 + return 146 + } 147 + 148 + listEl.innerHTML = filtered.map((e) => this.entryHtml(e)).join('') 149 + } 150 + 151 + private entryHtml(e: ClimbLogEntry): string { 152 + const grade = gradeLabel(e.grade, this.gradeScale) 153 + const stars = starsHtml(e.rating) 154 + const badge = e.sent 155 + ? '<span class="lb-badge lb-badge--sent">Sent</span>' 156 + : '<span class="lb-badge lb-badge--project">Project</span>' 157 + 158 + return ` 159 + <div class="lb-entry"> 160 + <div class="lb-entry-row"> 161 + <span class="lb-entry-name">${escapeHtml(e.name)}</span> 162 + <span class="lb-grade-pill">${grade}</span> 163 + ${badge} 164 + </div> 165 + <div class="lb-entry-meta"> 166 + <span>${e.totalAttempts} attempt${ 167 + e.totalAttempts === 1 ? '' : 's' 168 + }</span> 169 + ${stars ? `<span class="lb-entry-stars">${stars}</span>` : ''} 170 + <span>${escapeHtml(e.setter)}</span> 171 + <span class="lb-entry-date">${formatDate(e.lastAttempted)}</span> 172 + </div> 173 + </div> 174 + ` 175 + } 176 + } 177 + 178 + customElements.define('library-page', LibraryPage)
+14
www/static/icons/library.svg
··· 1 + <svg 2 + xmlns="http://www.w3.org/2000/svg" 3 + width="24" 4 + height="24" 5 + viewBox="0 0 24 24" 6 + fill="none" 7 + stroke="currentColor" 8 + stroke-width="2" 9 + stroke-linecap="round" 10 + stroke-linejoin="round" 11 + > 12 + <path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" /> 13 + <path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" /> 14 + </svg>
+313
www/static/theme.css
··· 609 609 border-color: var(--primary); 610 610 background: var(--primary-dull); 611 611 } 612 + 613 + /* ── Logbook: shared badges ─────────────────────────────────────────────── */ 614 + 615 + .lb-badge { 616 + display: inline-block; 617 + padding: 2px var(--s2); 618 + border-radius: var(--br-full); 619 + font-size: var(--f7); 620 + font-weight: var(--fw-semibold); 621 + white-space: nowrap; 622 + flex-shrink: 0; 623 + } 624 + 625 + .lb-badge--sent { 626 + background: hsl(var(--greenH), var(--greenS), var(--greenL)); 627 + color: #fff; 628 + } 629 + 630 + .lb-badge--project { 631 + border: 1px solid currentColor; 632 + opacity: 0.6; 633 + } 634 + 635 + .lb-entry-stars { 636 + letter-spacing: 0.05em; 637 + } 638 + 639 + /* ── Logbook: log section in climb detail ───────────────────────────────── */ 640 + 641 + .lb-log-section { 642 + margin-top: var(--s3); 643 + padding-top: var(--s3); 644 + border-top: 1px solid currentColor; 645 + display: flex; 646 + flex-direction: column; 647 + gap: var(--s2); 648 + } 649 + 650 + .lb-log-summary { 651 + display: flex; 652 + align-items: center; 653 + gap: var(--s2); 654 + font-size: var(--f5); 655 + flex-wrap: wrap; 656 + } 657 + 658 + .lb-log-btn { 659 + display: block; 660 + width: 100%; 661 + padding: var(--s2) var(--s3); 662 + background: transparent; 663 + border: 1px solid currentColor; 664 + border-radius: var(--br-base); 665 + color: inherit; 666 + font-size: var(--f5); 667 + font-weight: var(--fw-medium); 668 + cursor: pointer; 669 + text-align: center; 670 + transition: opacity var(--transition-fast); 671 + } 672 + 673 + .lb-log-btn:hover { 674 + opacity: 0.7; 675 + } 676 + 677 + /* ── Logbook: session dialog ────────────────────────────────────────────── */ 678 + 679 + .lb-overlay { 680 + position: absolute; 681 + inset: 0; 682 + background: rgba(0, 0, 0, 0.5); 683 + display: flex; 684 + align-items: center; 685 + justify-content: center; 686 + z-index: 20; 687 + padding: var(--s3); 688 + } 689 + 690 + .lb-overlay[hidden] { 691 + display: none; 692 + } 693 + 694 + .lb-dialog { 695 + background: var(--background); 696 + border: 1px solid currentColor; 697 + border-radius: var(--br-lg); 698 + padding: var(--s4); 699 + width: min(340px, 100%); 700 + display: flex; 701 + flex-direction: column; 702 + gap: var(--s3); 703 + } 704 + 705 + .lb-dialog-header { 706 + display: flex; 707 + align-items: center; 708 + justify-content: space-between; 709 + } 710 + 711 + .lb-dialog-title { 712 + font-weight: var(--fw-semibold); 713 + font-size: var(--f3); 714 + } 715 + 716 + .lb-dialog-close { 717 + background: none; 718 + border: none; 719 + cursor: pointer; 720 + font-size: var(--f3); 721 + opacity: 0.6; 722 + padding: var(--s1); 723 + line-height: 1; 724 + color: inherit; 725 + } 726 + 727 + .lb-dialog-close:hover { 728 + opacity: 1; 729 + } 730 + 731 + .lb-field { 732 + display: flex; 733 + flex-direction: column; 734 + gap: var(--s1); 735 + } 736 + 737 + .lb-field--row { 738 + flex-direction: row; 739 + align-items: center; 740 + justify-content: space-between; 741 + } 742 + 743 + .lb-field-label { 744 + font-size: var(--f6); 745 + font-weight: var(--fw-medium); 746 + opacity: 0.7; 747 + } 748 + 749 + .lb-counter { 750 + display: flex; 751 + align-items: center; 752 + gap: var(--s2); 753 + } 754 + 755 + .lb-counter-btn { 756 + display: flex; 757 + align-items: center; 758 + justify-content: center; 759 + width: 40px; 760 + height: 40px; 761 + border: 1px solid currentColor; 762 + border-radius: var(--br-base); 763 + background: transparent; 764 + color: inherit; 765 + font-size: var(--f2); 766 + cursor: pointer; 767 + flex-shrink: 0; 768 + } 769 + 770 + .lb-counter-input { 771 + flex: 1; 772 + text-align: center; 773 + border: 1px solid currentColor; 774 + border-radius: var(--br-base); 775 + background: transparent; 776 + color: inherit; 777 + font-size: var(--f3); 778 + font-weight: var(--fw-semibold); 779 + padding: var(--s2); 780 + font-variant-numeric: tabular-nums; 781 + -moz-appearance: textfield; 782 + } 783 + 784 + .lb-counter-input::-webkit-inner-spin-button, 785 + .lb-counter-input::-webkit-outer-spin-button { 786 + -webkit-appearance: none; 787 + } 788 + 789 + .lb-checkbox { 790 + width: 22px; 791 + height: 22px; 792 + accent-color: var(--primary); 793 + cursor: pointer; 794 + flex-shrink: 0; 795 + } 796 + 797 + .lb-star-row { 798 + display: flex; 799 + gap: var(--s1); 800 + } 801 + 802 + .lb-star-btn { 803 + background: none; 804 + border: none; 805 + font-size: 1.75rem; 806 + cursor: pointer; 807 + color: var(--border); 808 + padding: 0 var(--s1); 809 + line-height: 1; 810 + transition: color var(--transition-fast); 811 + } 812 + 813 + .lb-star-btn--active { 814 + color: hsl(var(--yellowH), var(--yellowS), var(--yellowL)); 815 + } 816 + 817 + .lb-submit-btn { 818 + width: 100%; 819 + padding: var(--s3); 820 + background: var(--primary); 821 + color: var(--background); 822 + border: none; 823 + border-radius: var(--br-base); 824 + font-size: var(--f4); 825 + font-weight: var(--fw-semibold); 826 + cursor: pointer; 827 + transition: opacity var(--transition-fast); 828 + } 829 + 830 + .lb-submit-btn:hover { 831 + opacity: 0.85; 832 + } 833 + 834 + /* ── Library page ────────────────────────────────────────────────────────── */ 835 + 836 + .lb-page { 837 + display: flex; 838 + flex-direction: column; 839 + min-height: calc(100vh - var(--header-height) - var(--footer-height)); 840 + } 841 + 842 + .lb-filter-bar { 843 + display: flex; 844 + gap: var(--s2); 845 + padding: var(--s3); 846 + border-bottom: 1px solid currentColor; 847 + position: sticky; 848 + top: var(--header-height, 56px); 849 + background: var(--background); 850 + z-index: 10; 851 + } 852 + 853 + .lb-filter-btn { 854 + flex: 1; 855 + padding: var(--s2); 856 + border: 1px solid currentColor; 857 + border-radius: var(--br-full); 858 + background: transparent; 859 + color: inherit; 860 + font-size: var(--f6); 861 + font-weight: var(--fw-medium); 862 + cursor: pointer; 863 + transition: all var(--transition-fast); 864 + white-space: nowrap; 865 + } 866 + 867 + .lb-filter-btn--active { 868 + background: var(--primary); 869 + color: var(--background); 870 + border-color: var(--primary); 871 + } 872 + 873 + .lb-empty { 874 + padding: var(--s6) var(--s3); 875 + text-align: center; 876 + opacity: 0.6; 877 + line-height: var(--lh-base); 878 + } 879 + 880 + .lb-entry { 881 + padding: var(--s3); 882 + border-bottom: 1px solid currentColor; 883 + display: flex; 884 + flex-direction: column; 885 + gap: var(--s1); 886 + } 887 + 888 + .lb-entry-row { 889 + display: flex; 890 + align-items: center; 891 + gap: var(--s2); 892 + } 893 + 894 + .lb-entry-name { 895 + flex: 1; 896 + font-weight: var(--fw-semibold); 897 + font-size: var(--f5); 898 + overflow: hidden; 899 + text-overflow: ellipsis; 900 + white-space: nowrap; 901 + } 902 + 903 + .lb-grade-pill { 904 + font-size: var(--f7); 905 + font-weight: var(--fw-semibold); 906 + padding: 2px var(--s2); 907 + border: 1px solid currentColor; 908 + border-radius: var(--br-full); 909 + white-space: nowrap; 910 + flex-shrink: 0; 911 + } 912 + 913 + .lb-entry-meta { 914 + display: flex; 915 + align-items: center; 916 + gap: var(--s2); 917 + font-size: var(--f6); 918 + opacity: 0.6; 919 + flex-wrap: wrap; 920 + } 921 + 922 + .lb-entry-date { 923 + margin-left: auto; 924 + }
+84
www/utils/logbook.ts
··· 1 + export interface ClimbSession { 2 + date: string 3 + attempts: number 4 + sent: boolean 5 + rating: number | null 6 + } 7 + 8 + export interface ClimbLogEntry { 9 + climbId: number 10 + mbType: number 11 + name: string 12 + grade: number 13 + setter: string 14 + sent: boolean 15 + totalAttempts: number 16 + rating: number | null 17 + sessions: ClimbSession[] 18 + lastAttempted: string 19 + } 20 + 21 + const KEY = 'mb_logbook' 22 + 23 + export function loadLogbook(): Record<string, ClimbLogEntry> { 24 + try { 25 + return JSON.parse(localStorage.getItem(KEY) ?? '{}') 26 + } catch { 27 + return {} 28 + } 29 + } 30 + 31 + function saveLogbook(logbook: Record<string, ClimbLogEntry>): void { 32 + localStorage.setItem(KEY, JSON.stringify(logbook)) 33 + } 34 + 35 + export function getClimbLog(climbId: number): ClimbLogEntry | null { 36 + return loadLogbook()[climbId.toString()] ?? null 37 + } 38 + 39 + export function logSession( 40 + climb: { 41 + id: number 42 + mbType: number 43 + name: string 44 + grade: number 45 + setter: string 46 + }, 47 + session: { attempts: number; sent: boolean; rating: number | null }, 48 + ): ClimbLogEntry { 49 + const logbook = loadLogbook() 50 + const key = climb.id.toString() 51 + const existing = logbook[key] 52 + 53 + const newSession: ClimbSession = { 54 + date: new Date().toISOString(), 55 + attempts: session.attempts, 56 + sent: session.sent, 57 + rating: session.rating, 58 + } 59 + 60 + const sessions = existing ? [...existing.sessions, newSession] : [newSession] 61 + const totalAttempts = sessions.reduce((sum, s) => sum + s.attempts, 0) 62 + const sent = sessions.some((s) => s.sent) 63 + const ratedSessions = sessions.filter((s) => s.rating !== null) 64 + const rating = ratedSessions.length > 0 65 + ? ratedSessions[ratedSessions.length - 1].rating 66 + : null 67 + 68 + const entry: ClimbLogEntry = { 69 + climbId: climb.id, 70 + mbType: climb.mbType, 71 + name: climb.name, 72 + grade: climb.grade, 73 + setter: climb.setter, 74 + sent, 75 + totalAttempts, 76 + rating, 77 + sessions, 78 + lastAttempted: newSession.date, 79 + } 80 + 81 + logbook[key] = entry 82 + saveLogbook(logbook) 83 + return entry 84 + }