Spaces:
Runtime error
Runtime error
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>SDR Status Tracker</title> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;900&display=swap" rel="stylesheet"> | |
| <script src="https://cdn.jsdelivr.net/npm/howler@2.2.4/dist/howler.min.js"></script> | |
| <style> | |
| :root { | |
| /* ScaleupXQ Brand Colors - Primary */ | |
| --primary: #6D3EF3; | |
| --primary-dark: #602DF2; | |
| /* ScaleupXQ Color Families - 300 level (accents) */ | |
| --lilac-300: #B89EFF; | |
| --tangerine-300: #FF8474; | |
| --emerald-300: #70E0B1; | |
| --marigold-300: #FFF09B; | |
| --zodiac-300: #ABECFA; | |
| --sierra-300: #FAB987; | |
| /* ScaleupXQ Color Families - 100 level (light backgrounds) */ | |
| --lilac-100: #F4F0FF; | |
| --tangerine-100: #FFEBE9; | |
| --emerald-100: #E8FAF3; | |
| --marigold-100: #FFFDEF; | |
| --zodiac-100: #F2FCFE; | |
| --sierra-100: #FEF4EC; | |
| /* ScaleupXQ Color Families - 400 level (dark text/accents) */ | |
| --lilac-400: #1E0C32; | |
| --emerald-400: #00302A; | |
| --tangerine-400: #391418; | |
| /* Base colors */ | |
| --base-100: #FFFFFF; | |
| --base-200: #F5F5F6; | |
| --base-300: #D8D8D9; | |
| --base-400: #B2B1B3; | |
| --base-500: #6E6D71; | |
| --base-600: #0D0B13; | |
| } | |
| /* Dark Theme */ | |
| [data-theme="dark"] { | |
| --bg-main: var(--base-600); | |
| --bg-gradient: linear-gradient(135deg, #080710 0%, #0D0B13 100%); | |
| --bg-card: rgba(109, 62, 243, 0.08); | |
| --border: rgba(109, 62, 243, 0.25); | |
| --text: var(--base-200); | |
| --text-muted: var(--base-400); | |
| --text-inverse: var(--base-600); | |
| /* Use 300 level colors (vibrant) for dark theme */ | |
| --success: var(--emerald-300); | |
| --warning: var(--marigold-300); | |
| --danger: var(--tangerine-300); | |
| --info: var(--zodiac-300); | |
| --chart-grid: rgba(255,255,255,0.1); | |
| --chart-text: var(--base-400); | |
| --input-bg: rgba(255,255,255,0.08); | |
| --modal-bg: var(--base-600); | |
| } | |
| /* Light Theme - ScaleupXQ Website Style */ | |
| [data-theme="light"] { | |
| --bg-main: var(--base-100); | |
| --bg-gradient: var(--base-100); | |
| --bg-card: var(--base-100); | |
| --border: var(--base-300); | |
| --text: var(--base-600); | |
| --text-muted: var(--base-500); | |
| --text-inverse: var(--base-100); | |
| /* Use 300 level colors (vibrant) - same as dark for consistency */ | |
| --success: var(--emerald-300); | |
| --warning: var(--marigold-300); | |
| --danger: var(--tangerine-300); | |
| --info: var(--zodiac-300); | |
| --chart-grid: rgba(0,0,0,0.08); | |
| --chart-text: var(--base-500); | |
| --input-bg: var(--base-200); | |
| --modal-bg: var(--base-100); | |
| } | |
| * { box-sizing: border-box; margin: 0; padding: 0; } | |
| html { font-size: 20px; } /* Base size for rem units (default is 16px) */ | |
| body { | |
| font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; | |
| background: var(--bg-gradient); | |
| color: var(--text); | |
| min-height: 100vh; | |
| padding: 16px; | |
| } | |
| .container { max-width: 1600px; margin: 0 auto; } | |
| h1 { text-align: center; margin-bottom: 8px; color: var(--primary); font-size: 2.1rem; font-weight: 900; } | |
| .subtitle { text-align: center; color: var(--text-muted); margin-bottom: 20px; font-size: 1.1rem; } | |
| [data-theme="light"] .leaderboard-card { background: var(--base-100); border: 1px solid var(--base-300); } | |
| [data-theme="light"] table { background: var(--base-100); } | |
| [data-theme="light"] th { background: var(--lilac-100); } | |
| /* Theme Toggle */ | |
| .theme-toggle { | |
| position: fixed; top: 12px; right: 12px; z-index: 100; | |
| display: flex; gap: 4px; background: var(--bg-card); padding: 4px 8px; | |
| border-radius: 20px; border: 1px solid var(--border); | |
| } | |
| .theme-btn { | |
| background: transparent; border: none; padding: 5px 10px; | |
| border-radius: 14px; cursor: pointer; font-size: 0.75rem; font-weight: 600; | |
| color: var(--text-muted); transition: all 0.2s; | |
| } | |
| .theme-btn.active { | |
| background: var(--primary); color: #fff; | |
| } | |
| .theme-btn:hover:not(.active) { background: var(--border); } | |
| /* Month Selector */ | |
| .month-selector { | |
| position: fixed; top: 12px; left: 12px; z-index: 100; | |
| } | |
| .month-selector select { | |
| background: var(--bg-card); color: var(--text); | |
| border: 1px solid var(--border); border-radius: 8px; | |
| padding: 8px 32px 8px 12px; font-size: 0.85rem; font-weight: 600; | |
| cursor: pointer; font-family: 'Inter', sans-serif; | |
| appearance: none; -webkit-appearance: none; | |
| background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%236D3EF3' d='M6 8L2 4h8z'/%3E%3C/svg%3E"); | |
| background-repeat: no-repeat; | |
| background-position: right 10px center; | |
| } | |
| .month-selector select:hover { | |
| border-color: var(--primary); | |
| } | |
| .month-selector select:focus { | |
| outline: none; border-color: var(--primary); | |
| box-shadow: 0 0 0 2px rgba(109, 62, 243, 0.2); | |
| } | |
| [data-theme="dark"] .month-selector select { | |
| background-color: rgba(109, 62, 243, 0.15); | |
| background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23B89EFF' d='M6 8L2 4h8z'/%3E%3C/svg%3E"); | |
| } | |
| /* Main Layout - Table left, Leaderboard right */ | |
| .main-layout { | |
| display: grid; | |
| grid-template-columns: 1fr 280px; | |
| gap: 20px; | |
| margin-bottom: 20px; | |
| } | |
| /* Table Section */ | |
| .table-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; } | |
| .table-header h2 { color: var(--primary); font-size: 1.3rem; font-weight: 700; } | |
| .table-container { overflow-x: auto; border-radius: 10px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); } | |
| table { width: 100%; border-collapse: collapse; background: var(--bg-card); font-size: 1rem; } | |
| th, td { padding: 4px 5px; text-align: left; border-bottom: 1px solid var(--border); } | |
| th { background: var(--lilac-100); color: var(--primary); font-weight: 700; white-space: nowrap; font-size: 0.95rem; } | |
| tr:hover { background: var(--lilac-100); } | |
| .case-name { font-weight: 600; color: var(--text); font-size: 0.95rem; } | |
| /* SDR Grouping Styles */ | |
| .sdr-name-cell { vertical-align: middle; min-width: 100px; } | |
| .sdr-name-visible { background: var(--lilac-100); } | |
| .sdr-name-hidden { background: transparent; } | |
| .sdr-name { font-weight: 900; color: var(--primary); font-size: 1rem; } | |
| .sdr-group-first td { border-top: 2px solid var(--primary); } | |
| .sdr-group-last td { border-bottom: 2px solid var(--border); } | |
| tr.sdr-group-first:hover td.sdr-name-visible, | |
| tr:hover td.sdr-name-visible { background: var(--lilac-100); } | |
| [data-theme="dark"] .sdr-name-visible { background: rgba(109, 62, 243, 0.15); } | |
| [data-theme="dark"] tr:hover td.sdr-name-visible { background: rgba(109, 62, 243, 0.2); } | |
| /* Dark theme table overrides */ | |
| [data-theme="dark"] th { background: rgba(109, 62, 243, 0.2) ; } | |
| [data-theme="dark"] th:hover { background: rgba(109, 62, 243, 0.3) ; filter: none; } | |
| [data-theme="dark"] tr:hover { background: rgba(109, 62, 243, 0.08); } | |
| /* Weekly/Monthly divider */ | |
| .weekly-monthly-divider { border-left: 3px solid var(--primary) ; } | |
| /* Progress Bars - Vibrant with gradients */ | |
| .progress-cell { min-width: 90px; } | |
| .progress-bar { background: var(--base-200); border-radius: 5px; height: 22px; overflow: hidden; position: relative; box-shadow: inset 0 1px 3px rgba(0,0,0,0.1); } | |
| .progress-fill { | |
| height: 100%; border-radius: 5px; transition: width 0.3s; | |
| display: flex; align-items: center; padding-left: 6px; | |
| box-shadow: 0 1px 4px rgba(0,0,0,0.2), inset 0 1px 0 rgba(255,255,255,0.3); | |
| } | |
| .progress-text { font-size: 0.85rem; font-weight: 800; color: #000; white-space: nowrap; text-shadow: none; } | |
| /* Progress bar color classes with gradients */ | |
| .progress-success { background: linear-gradient(90deg, #70E0B1 0%, #ABECFA 100%); } /* Victory gradient: green to cyan */ | |
| .progress-good { background: linear-gradient(135deg, #FFF09B 0%, #FFE55C 50%, #FFF09B 100%); } /* Yellow - almost there */ | |
| .progress-warning { background: linear-gradient(135deg, #FAB987 0%, #F5A060 50%, #FAB987 100%); } /* Orange - halfway */ | |
| .progress-caution { background: linear-gradient(135deg, #FF8474 0%, #FF5C47 50%, #FF8474 100%); } /* Coral - needs work */ | |
| .progress-danger { background: linear-gradient(135deg, #FF5C47 0%, #E04535 50%, #FF5C47 100%); } /* Red - low */ | |
| .progress-none { background: var(--base-300); } /* Gray - no target set */ | |
| [data-theme="dark"] .progress-bar { background: rgba(255,255,255,0.08); } | |
| [data-theme="dark"] .progress-none { background: rgba(255,255,255,0.12); } | |
| /* Dark theme: muted, desaturated progress colors */ | |
| [data-theme="dark"] .progress-success { background: linear-gradient(90deg, #4A9B7A 0%, #5A8B9A 100%); } | |
| [data-theme="dark"] .progress-good { background: linear-gradient(90deg, #9A8A4A 0%, #8A7A3A 100%); } | |
| [data-theme="dark"] .progress-warning { background: linear-gradient(90deg, #9A6A4A 0%, #8A5A3A 100%); } | |
| [data-theme="dark"] .progress-caution { background: linear-gradient(90deg, #9A5A4A 0%, #8A4A3A 100%); } | |
| [data-theme="dark"] .progress-danger { background: linear-gradient(90deg, #8A4A4A 0%, #7A3A3A 100%); } | |
| [data-theme="dark"] .progress-text { color: #fff; text-shadow: 0 1px 2px rgba(0,0,0,0.5); } | |
| /* Progress text */ | |
| .progress-text { display: inline-block; } | |
| /* Leaderboard Section */ | |
| .leaderboard-card { | |
| background: var(--bg-card); border-radius: 12px; padding: 16px; | |
| border: 2px solid var(--primary); margin-bottom: 14px; | |
| box-shadow: 0 4px 16px rgba(109, 62, 243, 0.15); | |
| } | |
| .leaderboard-card h3 { color: var(--primary); margin-bottom: 14px; font-size: 1.25rem; font-weight: 900; display: flex; align-items: center; gap: 8px; text-transform: uppercase; letter-spacing: 0.5px; } | |
| .leaderboard-list { list-style: none; } | |
| .leaderboard-item { | |
| display: flex; align-items: center; | |
| padding: 10px 8px; border-bottom: 1px solid var(--border); | |
| border-radius: 8px; margin-bottom: 4px; transition: all 0.2s; | |
| } | |
| .leaderboard-item:last-child { border-bottom: none; margin-bottom: 0; } | |
| .leaderboard-item:hover { background: rgba(109, 62, 243, 0.08); } | |
| /* Top 3 get special highlight backgrounds */ | |
| .leaderboard-item.top-1 { background: linear-gradient(90deg, rgba(255,240,155,0.3), rgba(250,185,135,0.2)); border: 1px solid var(--marigold-300); } | |
| .leaderboard-item.top-2 { background: linear-gradient(90deg, rgba(216,216,217,0.4), rgba(178,177,179,0.2)); border: 1px solid var(--base-300); } | |
| .leaderboard-item.top-3 { background: linear-gradient(90deg, rgba(250,185,135,0.3), rgba(255,132,116,0.2)); border: 1px solid var(--sierra-300); } | |
| .leaderboard-rank { | |
| width: 36px; height: 36px; border-radius: 50%; display: flex; align-items: center; justify-content: center; | |
| font-weight: 900; font-size: 1.05rem; margin-right: 12px; flex-shrink: 0; | |
| } | |
| /* Using ScaleupXQ brand colors for ranks - bigger and bolder for top 3 */ | |
| .rank-1 { background: linear-gradient(135deg, var(--marigold-300), var(--sierra-300)); color: var(--base-600); box-shadow: 0 3px 12px rgba(255,240,155,0.6); width: 42px; height: 42px; font-size: 1.15rem; } | |
| .rank-2 { background: linear-gradient(135deg, var(--base-300), var(--base-400)); color: var(--base-600); box-shadow: 0 2px 8px rgba(0,0,0,0.15); width: 40px; height: 40px; font-size: 1.1rem; } | |
| .rank-3 { background: linear-gradient(135deg, var(--sierra-300), var(--tangerine-300)); color: #fff; box-shadow: 0 2px 8px rgba(250,185,135,0.5); width: 38px; height: 38px; font-size: 1.05rem; } | |
| .rank-other { background: var(--base-200); border: 1px solid var(--border); color: var(--text-muted); } | |
| .leaderboard-score { font-weight: 900; color: var(--primary); margin-right: 10px; min-width: 50px; font-size: 1.1rem; } | |
| .leaderboard-name { flex: 1; font-weight: 700; color: var(--text); font-size: 1.05rem; } | |
| /* Dark theme: bright white names and labels for better visibility */ | |
| [data-theme="dark"] .leaderboard-name { color: var(--base-100); } | |
| [data-theme="dark"] .leaderboard-score { color: var(--lilac-300); } | |
| [data-theme="dark"] .sdr-name { color: var(--base-100); } | |
| [data-theme="dark"] .case-name { color: var(--base-100); } | |
| [data-theme="dark"] th { color: var(--base-100) ; } | |
| /* Tiered scaling: Position (1st-5th) x Performance (tiers based on %) | |
| 200% is exceptional - targets are accurate, so exceeding is impressive! */ | |
| /* Position 1 base sizes */ | |
| .leaderboard-item.top-1 .leaderboard-name { font-size: 1.25rem; color: var(--base-600); } | |
| .leaderboard-item.top-1 .leaderboard-score { font-size: 1.35rem; } | |
| /* Position 1 + performance tiers - scaling for high achievers */ | |
| .leaderboard-item.top-1.tier-200 .leaderboard-name { font-size: 1.55rem; font-weight: 900; } | |
| .leaderboard-item.top-1.tier-200 .leaderboard-score { font-size: 1.65rem; } | |
| .leaderboard-item.top-1.tier-150 .leaderboard-name { font-size: 1.4rem; } | |
| .leaderboard-item.top-1.tier-150 .leaderboard-score { font-size: 1.5rem; } | |
| .leaderboard-item.top-1.tier-100 .leaderboard-name { font-size: 1.3rem; } | |
| .leaderboard-item.top-1.tier-100 .leaderboard-score { font-size: 1.4rem; } | |
| /* Position 2 base sizes */ | |
| .leaderboard-item.top-2 .leaderboard-name { font-size: 1.15rem; } | |
| .leaderboard-item.top-2 .leaderboard-score { font-size: 1.25rem; } | |
| /* Position 2 + performance tiers */ | |
| .leaderboard-item.top-2.tier-200 .leaderboard-name { font-size: 1.4rem; font-weight: 900; } | |
| .leaderboard-item.top-2.tier-200 .leaderboard-score { font-size: 1.5rem; } | |
| .leaderboard-item.top-2.tier-150 .leaderboard-name { font-size: 1.3rem; } | |
| .leaderboard-item.top-2.tier-150 .leaderboard-score { font-size: 1.35rem; } | |
| .leaderboard-item.top-2.tier-100 .leaderboard-name { font-size: 1.2rem; } | |
| .leaderboard-item.top-2.tier-100 .leaderboard-score { font-size: 1.3rem; } | |
| /* Position 3 base sizes */ | |
| .leaderboard-item.top-3 .leaderboard-name { font-size: 1.1rem; } | |
| .leaderboard-item.top-3 .leaderboard-score { font-size: 1.15rem; } | |
| /* Position 3 + performance tiers */ | |
| .leaderboard-item.top-3.tier-200 .leaderboard-name { font-size: 1.3rem; font-weight: 900; } | |
| .leaderboard-item.top-3.tier-200 .leaderboard-score { font-size: 1.35rem; } | |
| .leaderboard-item.top-3.tier-150 .leaderboard-name { font-size: 1.2rem; } | |
| .leaderboard-item.top-3.tier-150 .leaderboard-score { font-size: 1.25rem; } | |
| .leaderboard-item.top-3.tier-100 .leaderboard-name { font-size: 1.15rem; } | |
| .leaderboard-item.top-3.tier-100 .leaderboard-score { font-size: 1.2rem; } | |
| [data-theme="dark"] .rank-other { background: rgba(255,255,255,0.05); } | |
| /* Dark theme leaderboard - muted metallic colors */ | |
| [data-theme="dark"] .leaderboard-item.top-1 { background: linear-gradient(90deg, rgba(180,160,100,0.2), rgba(160,140,80,0.15)); border-color: rgba(180,160,100,0.4); } | |
| [data-theme="dark"] .leaderboard-item.top-2 { background: linear-gradient(90deg, rgba(160,165,175,0.2), rgba(140,145,155,0.15)); border-color: rgba(160,165,175,0.4); } | |
| [data-theme="dark"] .leaderboard-item.top-3 { background: linear-gradient(90deg, rgba(165,120,90,0.2), rgba(145,100,70,0.15)); border-color: rgba(165,120,90,0.4); } | |
| [data-theme="dark"] .rank-1 { background: linear-gradient(135deg, #8B7530, #6B5520); color: #fff; box-shadow: 0 3px 12px rgba(139,117,48,0.4); } | |
| [data-theme="dark"] .rank-2 { background: linear-gradient(135deg, #7A7D85, #5A5D65); color: #fff; box-shadow: 0 2px 8px rgba(122,125,133,0.3); } | |
| [data-theme="dark"] .rank-3 { background: linear-gradient(135deg, #8B5A3C, #6B4A2C); color: #fff; box-shadow: 0 2px 8px rgba(139,90,60,0.3); } | |
| [data-theme="dark"] .leaderboard-item.top-1 .leaderboard-name, | |
| [data-theme="dark"] .leaderboard-item.top-2 .leaderboard-name, | |
| [data-theme="dark"] .leaderboard-item.top-3 .leaderboard-name { color: var(--text); } | |
| /* Buttons */ | |
| .btn { | |
| padding: 8px 16px; border: none; border-radius: 6px; | |
| cursor: pointer; font-size: 0.85rem; font-weight: 600; transition: all 0.3s; | |
| } | |
| .btn-primary { background: var(--primary); color: #fff; } | |
| .btn-primary:hover { background: var(--primary-dark); } | |
| .btn-secondary { background: transparent; border: 2px solid var(--border); color: var(--text-muted); } | |
| /* Modal */ | |
| .modal-overlay { | |
| display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; | |
| background: rgba(0, 0, 0, 0.7); z-index: 1000; justify-content: center; align-items: center; | |
| } | |
| .modal-overlay.active { display: flex; } | |
| .modal { | |
| background: var(--modal-bg); border-radius: 12px; padding: 20px; | |
| width: 90%; max-width: 450px; border: 2px solid var(--primary); | |
| } | |
| .modal h2 { color: var(--primary); margin-bottom: 16px; font-size: 1.1rem; } | |
| .form-group { margin-bottom: 12px; } | |
| .form-group label { display: block; color: var(--text-muted); margin-bottom: 5px; font-size: 0.85rem; font-weight: 500; } | |
| .form-group input, .form-group select { | |
| width: 100%; padding: 8px; border: 2px solid var(--border); border-radius: 6px; | |
| background: var(--input-bg); color: var(--text); font-size: 0.9rem; | |
| } | |
| .modal-buttons { display: flex; gap: 10px; justify-content: flex-end; margin-top: 16px; } | |
| footer { text-align: center; color: var(--text-muted); padding: 16px; font-size: 0.8rem; } | |
| /* Save indicator */ | |
| .save-indicator { | |
| position: fixed; bottom: 20px; right: 20px; background: var(--success); | |
| color: #000; padding: 12px 20px; border-radius: 8px; font-weight: 600; | |
| opacity: 0; transform: translateY(20px); transition: all 0.3s; | |
| z-index: 1000; | |
| } | |
| .save-indicator.show { opacity: 1; transform: translateY(0); } | |
| @media (max-width: 1200px) { | |
| .main-layout { grid-template-columns: 1fr; } | |
| .leaderboard-section { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; } | |
| } | |
| @media (max-width: 768px) { | |
| th, td { padding: 6px 4px; font-size: 0.75rem; } | |
| h1 { font-size: 1.4rem; } | |
| } | |
| </style> | |
| </head> | |
| <body data-theme="light"> | |
| <!-- Month Selector --> | |
| <div class="month-selector"> | |
| <select id="monthSelect" onchange="changeMonth(this.value)"> | |
| <option value="">Loading...</option> | |
| </select> | |
| </div> | |
| <!-- Theme Toggle --> | |
| <div class="theme-toggle"> | |
| <button class="theme-btn active" data-theme="light" onclick="setTheme('light')">Light</button> | |
| <button class="theme-btn" data-theme="dark" onclick="setTheme('dark')">Dark</button> | |
| <button class="theme-btn" onclick="soundManager.toggle()" title="Toggle sound effects"> | |
| <span id="soundIcon">🔊</span> | |
| </button> | |
| <button class="theme-btn" onclick="openSoundSettings()" title="Sound settings">⚙️</button> | |
| </div> | |
| <div class="container"> | |
| <h1>SDR Status Tracker</h1> | |
| <p class="subtitle"><span id="currentMonthLabel">Loading...</span> - Week <span id="subtitleWeek"></span> Focus</p> | |
| <div class="main-layout"> | |
| <!-- Left: Main Table --> | |
| <div class="table-section"> | |
| <div class="table-header"> | |
| <h2>SDR Performance by Case</h2> | |
| </div> | |
| <div class="table-container"> | |
| <table> | |
| <thead> | |
| <tr> | |
| <th>SDR</th> | |
| <th>Case</th> | |
| <th colspan="6" style="text-align: center; background: var(--zodiac-100);">Current Week</th> | |
| <th colspan="2" class="weekly-monthly-divider" style="text-align: center; background: var(--emerald-100);">Monthly Totals</th> | |
| </tr> | |
| <tr> | |
| <th></th> | |
| <th></th> | |
| <th class="progress-cell">SQL %</th> | |
| <th class="progress-cell">Calls %</th> | |
| <th class="progress-cell">Emails %</th> | |
| <th class="progress-cell">LinkedIn %</th> | |
| <th class="progress-cell">Prospects %</th> | |
| <th class="progress-cell">Discovery %</th> | |
| <th class="progress-cell weekly-monthly-divider">SQL %</th> | |
| <th class="progress-cell">Activity %</th> | |
| </tr> | |
| </thead> | |
| <tbody id="caseTable"></tbody> | |
| </table> | |
| </div> | |
| </div> | |
| <!-- Right: Leaderboards --> | |
| <div class="leaderboard-section"> | |
| <div class="leaderboard-card" style="border-color: var(--zodiac-300);"> | |
| <h3>📅 Week <span id="currentWeekNum"></span> Score</h3> | |
| <ul class="leaderboard-list" id="weeklyLeaderboard"></ul> | |
| <p style="font-size: 0.7rem; color: var(--text-muted); margin-top: 10px;">60% SQL + 40% Activity (this week)</p> | |
| </div> | |
| <div class="leaderboard-card" style="border-color: var(--emerald-300);"> | |
| <h3>📊 Monthly Score</h3> | |
| <ul class="leaderboard-list" id="monthlyLeaderboard"></ul> | |
| <p style="font-size: 0.7rem; color: var(--text-muted); margin-top: 10px;">60% SQL + 40% Activity (full month)</p> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Sound Settings Modal --> | |
| <div class="modal-overlay" id="soundSettingsOverlay" onclick="closeSoundSettingsOnOverlay(event)"> | |
| <div class="modal" style="max-width: 500px;"> | |
| <h2>Sound Settings</h2> | |
| <div class="form-group"> | |
| <label style="display: flex; align-items: center; gap: 10px; cursor: pointer;"> | |
| <input type="checkbox" id="soundEnabled" onchange="soundManager.setEnabled(this.checked)" style="width: 18px; height: 18px;"> | |
| <span>Enable sounds</span> | |
| </label> | |
| </div> | |
| <hr style="border: none; border-top: 1px solid var(--border); margin: 16px 0;"> | |
| <div class="form-group"> | |
| <label>SQL Increment Sound</label> | |
| <div style="display: flex; gap: 8px;"> | |
| <select id="soundSqlIncrement" onchange="soundManager.setSoundChoice('sqlIncrement', this.value)" style="flex: 1;"> | |
| <option value="chime1">Chime 1</option> | |
| <option value="chime2">Chime 2</option> | |
| <option value="chime3" selected>Chime 3</option> | |
| <option value="chime4">Chime 4</option> | |
| <option value="chime5">Chime 5</option> | |
| <option value="bling1">Bling 1</option> | |
| <option value="bling2">Bling 2</option> | |
| <option value="bling3">Bling 3</option> | |
| <option value="click1">Click 1</option> | |
| <option value="click2">Click 2</option> | |
| <option value="none">None</option> | |
| </select> | |
| <button class="btn btn-secondary" onclick="soundManager.previewSound('sqlIncrement')" style="padding: 8px 12px;">▶</button> | |
| </div> | |
| </div> | |
| <div class="form-group"> | |
| <label>Target Hit (100%) Sound</label> | |
| <div style="display: flex; gap: 8px;"> | |
| <select id="soundTargetHit" onchange="soundManager.setSoundChoice('targetHit', this.value)" style="flex: 1;"> | |
| <option value="fanfare1" selected>Fanfare 1</option> | |
| <option value="fanfare2">Fanfare 2</option> | |
| <option value="jingle1">Jingle 1</option> | |
| <option value="jingle2">Jingle 2</option> | |
| <option value="jingle3">Jingle 3</option> | |
| <option value="scale1">Scale 1</option> | |
| <option value="scale2">Scale 2</option> | |
| <option value="scale3">Scale 3</option> | |
| <option value="none">None</option> | |
| </select> | |
| <button class="btn btn-secondary" onclick="soundManager.previewSound('targetHit')" style="padding: 8px 12px;">▶</button> | |
| </div> | |
| </div> | |
| <div class="form-group"> | |
| <label>Beyond Target (Bonus) Sound</label> | |
| <div style="display: flex; gap: 8px;"> | |
| <select id="soundBeyondTarget" onchange="soundManager.setSoundChoice('beyondTarget', this.value)" style="flex: 1;"> | |
| <option value="bling1">Bling 1</option> | |
| <option value="bling2">Bling 2</option> | |
| <option value="bling3" selected>Bling 3</option> | |
| <option value="chime1">Chime 1</option> | |
| <option value="chime2">Chime 2</option> | |
| <option value="click1">Click 1</option> | |
| <option value="click2">Click 2</option> | |
| <option value="none">None</option> | |
| </select> | |
| <button class="btn btn-secondary" onclick="soundManager.previewSound('beyondTarget')" style="padding: 8px 12px;">▶</button> | |
| </div> | |
| </div> | |
| <div class="form-group"> | |
| <label>Volume</label> | |
| <input type="range" id="soundVolume" min="0" max="100" value="60" onchange="soundManager.setVolume(this.value / 100)" style="width: 100%;"> | |
| </div> | |
| <div class="modal-buttons"> | |
| <button type="button" class="btn btn-secondary" onclick="closeSoundSettings()">Close</button> | |
| </div> | |
| </div> | |
| </div> | |
| <footer> | |
| <p>SDR Status Tracker</p> | |
| <button class="btn btn-primary" onclick="refreshData()" style="margin-top: 10px;">Refresh Data</button> | |
| </footer> | |
| <!-- Save Indicator --> | |
| <div class="save-indicator" id="saveIndicator">Saved!</div> | |
| </div> | |
| <script> | |
| // Theme management | |
| function setTheme(theme) { | |
| document.body.setAttribute('data-theme', theme); | |
| localStorage.setItem('theme', theme); | |
| // Update toggle buttons | |
| document.querySelectorAll('.theme-btn').forEach(btn => { | |
| btn.classList.toggle('active', btn.dataset.theme === theme); | |
| }); | |
| // Refresh display | |
| refreshAll(); | |
| } | |
| // Load saved theme or default to light | |
| const savedTheme = localStorage.getItem('theme') || 'light'; | |
| document.body.setAttribute('data-theme', savedTheme); | |
| document.querySelectorAll('.theme-btn').forEach(btn => { | |
| btn.classList.toggle('active', btn.dataset.theme === savedTheme); | |
| }); | |
| // ==== SOUND MANAGER WITH SETTINGS ==== | |
| class SoundManager { | |
| constructor() { | |
| this.enabled = localStorage.getItem('soundEnabled') !== 'false'; | |
| this.volume = parseFloat(localStorage.getItem('soundVolume')) || 0.6; | |
| this.sounds = {}; | |
| this.loaded = false; | |
| // Load saved sound choices or use defaults | |
| const savedChoices = localStorage.getItem('soundChoices'); | |
| this.choices = savedChoices ? JSON.parse(savedChoices) : { | |
| sqlIncrement: 'none', | |
| targetHit: 'fanfare1', | |
| beyondTarget: 'none' | |
| }; | |
| } | |
| init() { | |
| if (this.loaded) return; | |
| const base = 'https://raw.githubusercontent.com/rse/soundfx/master/soundfx.d/'; | |
| // Load all available sounds | |
| const soundFiles = [ | |
| 'chime1', 'chime2', 'chime3', 'chime4', 'chime5', | |
| 'bling1', 'bling2', 'bling3', | |
| 'click1', 'click2', | |
| 'fanfare1', 'fanfare2', | |
| 'jingle1', 'jingle2', 'jingle3', | |
| 'scale1', 'scale2', 'scale3' | |
| ]; | |
| soundFiles.forEach(name => { | |
| this.sounds[name] = new Howl({ | |
| src: [base + name + '.mp3'], | |
| volume: this.volume | |
| }); | |
| }); | |
| this.loaded = true; | |
| } | |
| // Settings methods | |
| setEnabled(enabled) { | |
| this.enabled = enabled; | |
| localStorage.setItem('soundEnabled', enabled); | |
| this.updateToggleIcon(); | |
| if (enabled) { | |
| this.init(); | |
| this.playSound(this.choices.targetHit); | |
| } | |
| } | |
| setVolume(vol) { | |
| this.volume = vol; | |
| localStorage.setItem('soundVolume', vol); | |
| // Update all loaded sounds | |
| Object.values(this.sounds).forEach(sound => { | |
| sound.volume(vol); | |
| }); | |
| } | |
| setSoundChoice(scenario, soundName) { | |
| this.choices[scenario] = soundName; | |
| localStorage.setItem('soundChoices', JSON.stringify(this.choices)); | |
| } | |
| previewSound(scenario) { | |
| this.init(); | |
| const soundName = this.choices[scenario]; | |
| this.playSound(soundName); | |
| } | |
| playSound(name) { | |
| if (name === 'none' || !this.sounds[name]) return; | |
| this.sounds[name]?.play(); | |
| } | |
| toggle() { | |
| this.setEnabled(!this.enabled); | |
| } | |
| updateToggleIcon() { | |
| const icon = document.getElementById('soundIcon'); | |
| if (icon) icon.textContent = this.enabled ? '🔊' : '🔇'; | |
| } | |
| // Load UI state when settings modal opens | |
| loadSettingsUI() { | |
| document.getElementById('soundEnabled').checked = this.enabled; | |
| document.getElementById('soundVolume').value = this.volume * 100; | |
| document.getElementById('soundSqlIncrement').value = this.choices.sqlIncrement; | |
| document.getElementById('soundTargetHit').value = this.choices.targetHit; | |
| document.getElementById('soundBeyondTarget').value = this.choices.beyondTarget; | |
| } | |
| } | |
| // Sound settings modal functions | |
| function openSoundSettings() { | |
| soundManager.init(); | |
| soundManager.loadSettingsUI(); | |
| document.getElementById('soundSettingsOverlay').classList.add('active'); | |
| } | |
| function closeSoundSettings() { | |
| document.getElementById('soundSettingsOverlay').classList.remove('active'); | |
| } | |
| function closeSoundSettingsOnOverlay(event) { | |
| if (event.target === document.getElementById('soundSettingsOverlay')) { | |
| closeSoundSettings(); | |
| } | |
| } | |
| // Global sound manager instance | |
| const soundManager = new SoundManager(); | |
| // ScaleupXQ color palette | |
| const COLORS = { | |
| primary: '#6D3EF3', | |
| primaryDark: '#602DF2', | |
| // 300 level - vibrant accents (used for both themes) | |
| emerald300: '#70E0B1', | |
| marigold300: '#FFF09B', | |
| tangerine300: '#FF8474', | |
| zodiac300: '#ABECFA', | |
| lilac300: '#B89EFF', | |
| sierra300: '#FAB987', | |
| // 100 level - light backgrounds | |
| lilac100: '#F4F0FF', | |
| emerald100: '#E8FAF3', | |
| zodiac100: '#F2FCFE', | |
| // Base | |
| base500: '#6E6D71', | |
| base400: '#B2B1B3', | |
| base600: '#0D0B13' | |
| }; | |
| // Data is loaded dynamically from Google Sheets API | |
| // No hardcoded defaults - API is the source of truth | |
| // Current month's start week (set when month is selected) | |
| let currentStartWeek = 2; | |
| // Determine current week number based on day of month and start_week | |
| // Each month has 4 week slots, offset by start_week | |
| function getCurrentWeek() { | |
| const today = new Date(); | |
| const dayOfMonth = today.getDate(); | |
| // Week slots: days 1-7 = slot 0, 8-14 = slot 1, 15-21 = slot 2, 22+ = slot 3 | |
| let slot; | |
| if (dayOfMonth <= 7) slot = 0; | |
| else if (dayOfMonth <= 14) slot = 1; | |
| else if (dayOfMonth <= 21) slot = 2; | |
| else slot = 3; | |
| return currentStartWeek + slot; | |
| } | |
| // This will be recalculated when month data loads | |
| let currentWeek = getCurrentWeek(); | |
| // Data source: Google Sheets via API (with localStorage cache) | |
| const STORAGE_KEY = 'caseStatusTracker_data_v5'; | |
| let cases = []; // Loaded from API | |
| let dataLoaded = false; | |
| // Month management | |
| let availableMonths = []; | |
| let currentMonth = null; | |
| let currentMonthLabel = ''; | |
| // Get selected month from URL or localStorage | |
| function getSelectedMonth() { | |
| const params = new URLSearchParams(window.location.search); | |
| const urlMonth = params.get('month'); | |
| if (urlMonth) return urlMonth; | |
| const stored = localStorage.getItem('selectedMonth'); | |
| if (stored) return stored; | |
| return null; // Will use default from API | |
| } | |
| // Save selected month to URL and localStorage | |
| function saveSelectedMonth(monthId) { | |
| localStorage.setItem('selectedMonth', monthId); | |
| // Update URL without reload | |
| const url = new URL(window.location); | |
| url.searchParams.set('month', monthId); | |
| window.history.replaceState({}, '', url); | |
| } | |
| // Fetch available months from API | |
| async function fetchMonths() { | |
| try { | |
| const response = await fetch('/api/months'); | |
| if (!response.ok) throw new Error('API error: ' + response.status); | |
| const data = await response.json(); | |
| availableMonths = data.months || []; | |
| const defaultMonth = data.default_month; | |
| // Populate dropdown | |
| const select = document.getElementById('monthSelect'); | |
| select.innerHTML = availableMonths.map(m => | |
| `<option value="${m.id}">${m.label}</option>` | |
| ).join(''); | |
| // Set initial month | |
| const selected = getSelectedMonth() || defaultMonth; | |
| if (selected && availableMonths.some(m => m.id === selected)) { | |
| select.value = selected; | |
| currentMonth = selected; | |
| } else { | |
| currentMonth = defaultMonth; | |
| select.value = defaultMonth; | |
| } | |
| // Set start_week for the selected month and recalculate current week | |
| updateStartWeek(currentMonth); | |
| return currentMonth; | |
| } catch (e) { | |
| console.error('Failed to fetch months:', e); | |
| return null; | |
| } | |
| } | |
| // Update start_week based on selected month | |
| function updateStartWeek(monthId) { | |
| const month = availableMonths.find(m => m.id === monthId); | |
| if (month && month.start_week) { | |
| currentStartWeek = month.start_week; | |
| currentWeek = getCurrentWeek(); | |
| document.getElementById('subtitleWeek').textContent = currentWeek; | |
| console.log(`Month ${monthId}: start_week=${currentStartWeek}, currentWeek=${currentWeek}`); | |
| } | |
| } | |
| // Change month | |
| async function changeMonth(monthId) { | |
| if (monthId === currentMonth) return; | |
| currentMonth = monthId; | |
| saveSelectedMonth(monthId); | |
| // Update start_week and current week for the new month | |
| updateStartWeek(monthId); | |
| // Show loading state | |
| showLoadingState(); | |
| // Fetch data for new month | |
| const updated = await fetchFromAPI(); | |
| if (updated) { | |
| refreshAll(); | |
| } | |
| } | |
| // Update month display label | |
| function updateMonthDisplay(label) { | |
| currentMonthLabel = label; | |
| document.getElementById('currentMonthLabel').textContent = label; | |
| document.title = `SDR Status Tracker - ${label}`; | |
| } | |
| // Track SQL achievements for sound triggers | |
| function getSQLAchievements(caseData) { | |
| const achievements = new Set(); | |
| const week = currentWeek; | |
| caseData.forEach(c => { | |
| const weekData = c.weeks[week] || {}; | |
| if (weekData.sqlTarget > 0) { | |
| const progress = (weekData.sql || 0) / weekData.sqlTarget * 100; | |
| if (progress >= 100) { | |
| achievements.add(`${c.gs}-${c.case}-week`); | |
| } | |
| } | |
| }); | |
| return achievements; | |
| } | |
| async function fetchFromAPI(checkAchievements = false) { | |
| // Store current achievements before fetching | |
| const previousAchievements = checkAchievements && cases.length > 0 | |
| ? getSQLAchievements(cases) | |
| : new Set(); | |
| try { | |
| // Include month parameter if set | |
| let url = '/api/data'; | |
| if (currentMonth) { | |
| url += `?month=${encodeURIComponent(currentMonth)}`; | |
| } | |
| const response = await fetch(url); | |
| if (!response.ok) throw new Error('API error: ' + response.status); | |
| const data = await response.json(); | |
| if (data.cases && data.cases.length > 0) { | |
| cases = data.cases; | |
| // Use month-specific cache key | |
| const cacheKey = `${STORAGE_KEY}_${data.month || 'default'}`; | |
| localStorage.setItem(cacheKey, JSON.stringify(cases)); | |
| console.log('Data loaded from Google Sheets', data.cached ? '(cached)' : '(fresh)', '- ' + cases.length + ' cases', 'month:', data.month); | |
| dataLoaded = true; | |
| // Update month display if returned from API | |
| if (data.month_label) { | |
| updateMonthDisplay(data.month_label); | |
| } | |
| // Check for new SQL 100% achievements | |
| if (checkAchievements && previousAchievements.size >= 0) { | |
| const newAchievements = getSQLAchievements(cases); | |
| for (const achievement of newAchievements) { | |
| if (!previousAchievements.has(achievement)) { | |
| console.log('🎉 New SQL target hit:', achievement); | |
| soundManager.init(); | |
| soundManager.playSound(soundManager.choices.targetHit); | |
| break; // Play once even if multiple achievements | |
| } | |
| } | |
| } | |
| return true; | |
| } else { | |
| console.warn('API returned empty data'); | |
| } | |
| } catch (e) { | |
| console.error('API unavailable:', e.message); | |
| } | |
| return false; | |
| } | |
| function loadFromCache(monthId = null) { | |
| const cacheKey = `${STORAGE_KEY}_${monthId || currentMonth || 'default'}`; | |
| const saved = localStorage.getItem(cacheKey); | |
| if (saved) { | |
| try { | |
| const parsed = JSON.parse(saved); | |
| if (parsed && parsed.length > 0) { | |
| cases = parsed; | |
| dataLoaded = true; | |
| console.log('Loaded from cache:', cases.length, 'cases', 'month:', monthId || currentMonth); | |
| return true; | |
| } | |
| } catch (e) { | |
| console.error('Failed to parse cached data:', e); | |
| } | |
| } | |
| return false; | |
| } | |
| function showSaveIndicator() { | |
| const indicator = document.getElementById('saveIndicator'); | |
| indicator.classList.add('show'); | |
| setTimeout(() => indicator.classList.remove('show'), 1500); | |
| } | |
| function showLoadingState() { | |
| const tbody = document.getElementById('caseTable'); | |
| tbody.innerHTML = '<tr><td colspan="10" style="text-align: center; padding: 40px; color: var(--text-muted);">Loading data from Google Sheets...</td></tr>'; | |
| } | |
| function showErrorState(message) { | |
| const tbody = document.getElementById('caseTable'); | |
| tbody.innerHTML = `<tr><td colspan="10" style="text-align: center; padding: 40px; color: var(--danger);">${message}</td></tr>`; | |
| } | |
| // Initialize will be called after months are fetched | |
| // (moved to async init function below) | |
| // Get CSS class for progress bar gradient - TIME-AWARE version | |
| // Colors based on how far into the month we are vs achievement | |
| function getProgressClass(progress) { | |
| // If achieved 100%, always show success (bright green) | |
| if (progress >= 100) return 'progress-success'; | |
| // Calculate expected progress based on current day of month | |
| const today = new Date(); | |
| const dayOfMonth = today.getDate(); | |
| const daysInMonth = new Date(today.getFullYear(), today.getMonth() + 1, 0).getDate(); | |
| const expectedProgress = (dayOfMonth / daysInMonth) * 100; | |
| // Compare actual to expected | |
| if (progress >= expectedProgress) { | |
| return 'progress-good'; // On track - light green/yellow | |
| } else if (progress >= expectedProgress * 0.75) { | |
| return 'progress-warning'; // Slightly behind - orange | |
| } else if (progress >= expectedProgress * 0.5) { | |
| return 'progress-caution'; // Behind - coral | |
| } | |
| return 'progress-danger'; // Far behind - red | |
| } | |
| // Animate counting up with easing (only on first load, not on updates) | |
| function animateCount(element, targetValue, duration = 800, suffix = '%') { | |
| // Get the current displayed value to avoid going down to 0 | |
| const currentText = element.textContent || '0'; | |
| const currentNum = parseInt(currentText.replace(/[^0-9-]/g, '')) || 0; | |
| // If already at target, skip animation | |
| if (currentNum === targetValue) return; | |
| // Start from current value (not 0) to avoid counting down then up | |
| const startValue = currentNum; | |
| const startTime = performance.now(); | |
| function easeOutQuart(t) { | |
| return 1 - Math.pow(1 - t, 4); | |
| } | |
| function update(currentTime) { | |
| const elapsed = currentTime - startTime; | |
| const progress = Math.min(elapsed / duration, 1); | |
| const easedProgress = easeOutQuart(progress); | |
| const currentValue = Math.round(startValue + (targetValue - startValue) * easedProgress); | |
| element.textContent = currentValue + suffix; | |
| if (progress < 1) { | |
| requestAnimationFrame(update); | |
| } | |
| } | |
| requestAnimationFrame(update); | |
| } | |
| // Calculate overall score for an SDR (used for sorting SDR groups) - based on MONTHLY totals | |
| // Rules: | |
| // - Each case contributes proportionally (1/n of total where n = number of cases) | |
| // - SQL contributes 60% total, Activity contributes 40% total | |
| // - Each case's contribution is CAPPED at its proportional share until ALL targets are met | |
| // - Only when EVERY individual target (SQL + Activity per case) is at 100%+ does bonus unlock | |
| function calculateSDRScore(sdrCases) { | |
| const numCases = sdrCases.length; | |
| if (numCases === 0) return 0; | |
| // Each case gets equal share of the 60% SQL and 40% Activity pools | |
| const sqlSharePerCase = 60 / numCases; | |
| const activitySharePerCase = 40 / numCases; | |
| let totalScore = 0; | |
| let allTargetsMet = true; | |
| // First pass: check if ALL individual targets are met | |
| sdrCases.forEach(c => { | |
| const sqlProgress = c.monthlyTotal.sqlTarget > 0 ? (c.monthlyTotal.sql / c.monthlyTotal.sqlTarget) * 100 : 0; | |
| const activityProgress = c.monthlyTotal.activityTarget > 0 ? (c.monthlyTotal.activity / c.monthlyTotal.activityTarget) * 100 : 0; | |
| // If any target is not met (or has no target), not all targets are met | |
| if (sqlProgress < 100 || activityProgress < 100) { | |
| allTargetsMet = false; | |
| } | |
| // Also check if targets exist - no target means can't be "met" | |
| if (c.monthlyTotal.sqlTarget === 0 || c.monthlyTotal.activityTarget === 0) { | |
| allTargetsMet = false; | |
| } | |
| }); | |
| // Second pass: calculate score | |
| sdrCases.forEach(c => { | |
| const sqlProgress = c.monthlyTotal.sqlTarget > 0 ? (c.monthlyTotal.sql / c.monthlyTotal.sqlTarget) * 100 : 0; | |
| const activityProgress = c.monthlyTotal.activityTarget > 0 ? (c.monthlyTotal.activity / c.monthlyTotal.activityTarget) * 100 : 0; | |
| if (allTargetsMet) { | |
| // Uncapped: over-performance counts | |
| totalScore += (sqlProgress / 100) * sqlSharePerCase; | |
| totalScore += (activityProgress / 100) * activitySharePerCase; | |
| } else { | |
| // Capped: each case's contribution maxes at its proportional share | |
| totalScore += Math.min(sqlProgress / 100, 1) * sqlSharePerCase; | |
| totalScore += Math.min(activityProgress / 100, 1) * activitySharePerCase; | |
| } | |
| }); | |
| return totalScore; | |
| } | |
| // Get weekly progress for a case | |
| // Returns null for metrics without targets | |
| function getWeeklyProgress(c, weekNum) { | |
| const week = c.weeks[weekNum]; | |
| if (!week) return { sql: null, calls: null, emails: null, linkedin: null, prospects: null, discovery: null }; | |
| return { | |
| sql: week.sqlTarget > 0 ? Math.round((week.sql / week.sqlTarget) * 100) : null, | |
| calls: week.callsTarget > 0 ? Math.round((week.calls / week.callsTarget) * 100) : null, | |
| emails: week.emailsTarget > 0 ? Math.round((week.emails / week.emailsTarget) * 100) : null, | |
| linkedin: week.linkedinTarget > 0 ? Math.round((week.linkedin / week.linkedinTarget) * 100) : null, | |
| prospects: week.prospectsTarget > 0 ? Math.round((week.prospects / week.prospectsTarget) * 100) : null, | |
| discovery: week.discoveryTarget > 0 ? Math.round((week.discovery / week.discoveryTarget) * 100) : null | |
| }; | |
| } | |
| // Get monthly activity progress | |
| // Uses percentages from sheet (column AI), averaged across activity types | |
| function getMonthlyActivityProgress(c) { | |
| const pctList = c.monthlyTotal.activityPctList || []; | |
| if (pctList.length > 0) { | |
| const avg = pctList.reduce((a, b) => a + b, 0) / pctList.length; | |
| return Math.round(avg); | |
| } | |
| // Fallback to calculation if no percentages in sheet | |
| if (c.monthlyTotal.activityTarget === 0) return null; | |
| return Math.round((c.monthlyTotal.activity / c.monthlyTotal.activityTarget) * 100); | |
| } | |
| // Get monthly SQL progress | |
| // Uses percentages from sheet (column AI), averaged across SQL types | |
| function getMonthlySQLProgress(c) { | |
| const pctList = c.monthlyTotal.sqlPctList || []; | |
| if (pctList.length > 0) { | |
| const avg = pctList.reduce((a, b) => a + b, 0) / pctList.length; | |
| return Math.round(avg); | |
| } | |
| // Fallback to calculation if no percentages in sheet | |
| if (c.monthlyTotal.sqlTarget === 0) return null; | |
| return Math.round((c.monthlyTotal.sql / c.monthlyTotal.sqlTarget) * 100); | |
| } | |
| // Render a progress bar - handles null (no target) case | |
| function renderProgressBar(progress, isWeekly = true) { | |
| if (progress === null) { | |
| return ` | |
| <div class="progress-bar"> | |
| <div class="progress-fill progress-none" style="width: 100%"> | |
| <span class="progress-text">No target</span> | |
| </div> | |
| </div>`; | |
| } | |
| const progressClass = isWeekly ? getWeeklyProgressClass(progress) : getProgressClass(progress); | |
| return ` | |
| <div class="progress-bar"> | |
| <div class="progress-fill ${progressClass}" style="width: ${Math.min(progress, 100)}%"> | |
| <span class="progress-text" data-value="${progress}">${progress}%</span> | |
| </div> | |
| </div>`; | |
| } | |
| function renderTable() { | |
| const tbody = document.getElementById('caseTable'); | |
| tbody.innerHTML = ''; | |
| // Group cases by SDR/GS | |
| const sdrGroups = {}; | |
| cases.forEach(c => { | |
| if (!sdrGroups[c.gs]) { | |
| sdrGroups[c.gs] = []; | |
| } | |
| sdrGroups[c.gs].push(c); | |
| }); | |
| // Sort SDR groups by their overall score (descending) | |
| const sortedSDRs = Object.entries(sdrGroups) | |
| .map(([sdr, sdrCases]) => ({ | |
| sdr, | |
| cases: sdrCases, | |
| score: calculateSDRScore(sdrCases) | |
| })) | |
| .sort((a, b) => a.sdr.localeCompare(b.sdr)); | |
| // Render each SDR group | |
| sortedSDRs.forEach(({ sdr, cases: sdrCases, score }) => { | |
| // Sort cases within each SDR by monthly SQL progress (descending) | |
| sdrCases.sort((a, b) => { | |
| const progressA = getMonthlySQLProgress(a); | |
| const progressB = getMonthlySQLProgress(b); | |
| return progressB - progressA; | |
| }); | |
| // Render each case for this SDR | |
| sdrCases.forEach((c, idx) => { | |
| // Get current week's progress | |
| const weekProgress = getWeeklyProgress(c, currentWeek); | |
| // Get monthly totals | |
| const monthlySQLProgress = getMonthlySQLProgress(c); | |
| const monthlyActivityProgress = getMonthlyActivityProgress(c); | |
| const row = document.createElement('tr'); | |
| const isFirstInGroup = idx === 0; | |
| const isLastInGroup = idx === sdrCases.length - 1; | |
| // Add group styling | |
| if (isFirstInGroup) row.classList.add('sdr-group-first'); | |
| if (isLastInGroup) row.classList.add('sdr-group-last'); | |
| row.innerHTML = ` | |
| <td class="sdr-name-cell ${isFirstInGroup ? 'sdr-name-visible' : 'sdr-name-hidden'}"> | |
| ${isFirstInGroup ? `<div class="sdr-name">${sdr}</div>` : ''} | |
| </td> | |
| <td> | |
| <div class="case-name">${c.case}</div> | |
| </td> | |
| <td class="progress-cell">${renderProgressBar(weekProgress.sql, true)}</td> | |
| <td class="progress-cell">${renderProgressBar(weekProgress.calls, true)}</td> | |
| <td class="progress-cell">${renderProgressBar(weekProgress.emails, true)}</td> | |
| <td class="progress-cell">${renderProgressBar(weekProgress.linkedin, true)}</td> | |
| <td class="progress-cell">${renderProgressBar(weekProgress.prospects, true)}</td> | |
| <td class="progress-cell">${renderProgressBar(weekProgress.discovery, true)}</td> | |
| <td class="progress-cell weekly-monthly-divider">${renderProgressBar(monthlySQLProgress, false)}</td> | |
| <td class="progress-cell">${renderProgressBar(monthlyActivityProgress, false)}</td> | |
| `; | |
| tbody.appendChild(row); | |
| }); | |
| }); | |
| // Animate all progress text elements counting up | |
| setTimeout(() => { | |
| document.querySelectorAll('.progress-text[data-value]').forEach(el => { | |
| const targetValue = parseInt(el.dataset.value); | |
| animateCount(el, targetValue, 600 + Math.min(targetValue * 3, 600)); | |
| }); | |
| }, 50); | |
| } | |
| // Get CSS class for weekly progress bar (simpler: just compare to 100%) | |
| function getWeeklyProgressClass(progress) { | |
| if (progress >= 100) return 'progress-success'; | |
| if (progress >= 75) return 'progress-good'; | |
| if (progress >= 50) return 'progress-warning'; | |
| if (progress >= 25) return 'progress-caution'; | |
| return 'progress-danger'; | |
| } | |
| function renderLeaderboards() { | |
| // Update current week number display | |
| document.getElementById('currentWeekNum').textContent = currentWeek; | |
| // Group cases by SDR | |
| const gsCases = {}; | |
| cases.forEach(c => { | |
| if (!gsCases[c.gs]) gsCases[c.gs] = []; | |
| gsCases[c.gs].push(c); | |
| }); | |
| // === SCORING RULES === | |
| // Each individual target gets an equal share of the 100% total score. | |
| // Example: SDR with 2 cases, each having SQL + Calls + Emails targets = 6 targets total | |
| // Each target contributes max 100/6 = 16.67% to the total score. | |
| // | |
| // CRITICAL: Each target's contribution is CAPPED at its share until ALL targets are >= 100%. | |
| // Only after EVERY target hits 100%, over-performance on any target counts as bonus. | |
| // === WEEKLY LEADERBOARD === | |
| const weeklyByGS = {}; | |
| Object.entries(gsCases).forEach(([gs, gsCaseList]) => { | |
| // Collect all individual targets with their progress | |
| const targets = []; | |
| gsCaseList.forEach(c => { | |
| const week = c.weeks[currentWeek]; | |
| if (!week) return; | |
| // Add each target type if it has a target > 0 | |
| if (week.sqlTarget > 0) { | |
| targets.push({ actual: week.sql || 0, target: week.sqlTarget }); | |
| } | |
| if (week.callsTarget > 0) { | |
| targets.push({ actual: week.calls || 0, target: week.callsTarget }); | |
| } | |
| if (week.emailsTarget > 0) { | |
| targets.push({ actual: week.emails || 0, target: week.emailsTarget }); | |
| } | |
| if (week.linkedinTarget > 0) { | |
| targets.push({ actual: week.linkedin || 0, target: week.linkedinTarget }); | |
| } | |
| if (week.prospectsTarget > 0) { | |
| targets.push({ actual: week.prospects || 0, target: week.prospectsTarget }); | |
| } | |
| if (week.discoveryTarget > 0) { | |
| targets.push({ actual: week.discovery || 0, target: week.discoveryTarget }); | |
| } | |
| }); | |
| weeklyByGS[gs] = calculateScore(targets); | |
| }); | |
| const weeklyRanking = Object.entries(weeklyByGS).sort((a, b) => b[1] - a[1]).slice(0, 5); | |
| renderLeaderboard('weeklyLeaderboard', weeklyRanking, '%'); | |
| // === MONTHLY LEADERBOARD === | |
| const monthlyByGS = {}; | |
| Object.entries(gsCases).forEach(([gs, gsCaseList]) => { | |
| // Collect all percentages from sheet (column AI) | |
| const percentages = []; | |
| gsCaseList.forEach(c => { | |
| // Add SQL percentages from sheet | |
| const sqlPctList = c.monthlyTotal.sqlPctList || []; | |
| sqlPctList.forEach(pct => percentages.push(pct)); | |
| // Add Activity percentages from sheet | |
| const actPctList = c.monthlyTotal.activityPctList || []; | |
| actPctList.forEach(pct => percentages.push(pct)); | |
| }); | |
| // Calculate score with capping: can't exceed 100% unless ALL targets are met | |
| monthlyByGS[gs] = calculateScoreFromPercentages(percentages); | |
| }); | |
| const monthlyRanking = Object.entries(monthlyByGS).sort((a, b) => b[1] - a[1]).slice(0, 5); | |
| renderLeaderboard('monthlyLeaderboard', monthlyRanking, '%'); | |
| } | |
| // Calculate score from an array of percentages (from sheet column AI) | |
| // Each percentage gets equal share of 100%. Capped at its share until ALL are >= 100%. | |
| function calculateScoreFromPercentages(percentages) { | |
| if (percentages.length === 0) return 0; | |
| const sharePerTarget = 100 / percentages.length; | |
| // First pass: check if ALL targets are met (>= 100%) | |
| const allTargetsMet = percentages.every(pct => pct >= 100); | |
| // Second pass: calculate score | |
| let totalScore = 0; | |
| percentages.forEach(pct => { | |
| if (allTargetsMet) { | |
| // Uncapped: over-performance counts | |
| totalScore += (pct / 100) * sharePerTarget; | |
| } else { | |
| // Capped: each target contributes at most its share | |
| totalScore += Math.min(pct / 100, 1) * sharePerTarget; | |
| } | |
| }); | |
| return Math.round(totalScore); | |
| } | |
| // Calculate score from an array of {actual, target} objects | |
| // Each target gets equal share of 100%. Capped at its share until ALL targets >= 100%. | |
| function calculateScore(targets) { | |
| if (targets.length === 0) return 0; | |
| const sharePerTarget = 100 / targets.length; | |
| // First pass: check if ALL targets are met (>= 100%) | |
| let allTargetsMet = true; | |
| targets.forEach(t => { | |
| const pct = (t.actual / t.target) * 100; | |
| if (pct < 100) allTargetsMet = false; | |
| }); | |
| // Second pass: calculate score | |
| let totalScore = 0; | |
| targets.forEach(t => { | |
| const pct = (t.actual / t.target) * 100; | |
| if (allTargetsMet) { | |
| // Uncapped: over-performance counts | |
| totalScore += (pct / 100) * sharePerTarget; | |
| } else { | |
| // Capped: each target contributes at most its share | |
| totalScore += Math.min(pct / 100, 1) * sharePerTarget; | |
| } | |
| }); | |
| return Math.round(totalScore); | |
| } | |
| function getPerformanceTier(score) { | |
| // Performance tiers for scaling - only applies when above 100% | |
| if (score >= 200) return 'tier-200'; | |
| if (score >= 150) return 'tier-150'; | |
| if (score >= 100) return 'tier-100'; | |
| return ''; | |
| } | |
| function renderLeaderboard(elementId, data, suffix = '') { | |
| const list = document.getElementById(elementId); | |
| list.innerHTML = data.map(([name, score], i) => { | |
| const positionClass = i < 3 ? 'top-' + (i + 1) : ''; | |
| const tierClass = getPerformanceTier(score); | |
| return ` | |
| <li class="leaderboard-item ${positionClass} ${tierClass}"> | |
| <span class="leaderboard-rank ${i < 3 ? 'rank-' + (i + 1) : 'rank-other'}">${i + 1}</span> | |
| <span class="leaderboard-score">${score}${suffix}</span> | |
| <span class="leaderboard-name">${name}</span> | |
| </li> | |
| `}).join(''); | |
| } | |
| function updateAll() { | |
| renderLeaderboards(); | |
| } | |
| function refreshAll() { | |
| renderTable(); | |
| updateAll(); | |
| } | |
| // Refresh data from Google Sheets API | |
| async function refreshData() { | |
| const btn = document.querySelector('button[onclick="refreshData()"]'); | |
| if (btn) btn.textContent = 'Refreshing...'; | |
| // First, invalidate the backend cache for current month | |
| try { | |
| await fetch(`/api/invalidate-cache?month=${encodeURIComponent(currentMonth)}`, { | |
| method: 'POST' | |
| }); | |
| console.log('[Refresh] Cache invalidated for month:', currentMonth); | |
| } catch (e) { | |
| console.warn('[Refresh] Cache invalidation failed:', e); | |
| } | |
| // Then fetch fresh data | |
| const updated = await fetchFromAPI(); | |
| if (updated) { | |
| refreshAll(); | |
| showSaveIndicator(); | |
| } | |
| if (btn) btn.textContent = 'Refresh Data'; | |
| } | |
| // Initialize | |
| document.getElementById('subtitleWeek').textContent = currentWeek; | |
| soundManager.updateToggleIcon(); | |
| // Async initialization: fetch months first, then data | |
| async function initApp() { | |
| // Show loading state | |
| showLoadingState(); | |
| // Fetch available months and set current month | |
| await fetchMonths(); | |
| // Try to load from cache for this month | |
| loadFromCache(currentMonth); | |
| if (dataLoaded) { | |
| renderTable(); | |
| updateAll(); | |
| } | |
| // Fetch fresh data from API | |
| const updated = await fetchFromAPI(); | |
| if (updated) { | |
| refreshAll(); | |
| } else if (!dataLoaded) { | |
| showErrorState('Failed to load data. Please check your connection and try refreshing.'); | |
| } | |
| } | |
| // Start initialization | |
| initApp(); | |
| // Server-Sent Events for instant updates when webhook fires | |
| function connectSSE() { | |
| const eventSource = new EventSource('/api/events'); | |
| eventSource.onmessage = async (event) => { | |
| if (event.data === 'refresh') { | |
| console.log('SSE: Refresh triggered by webhook'); | |
| const updated = await fetchFromAPI(true); // Check for new SQL achievements | |
| if (updated) { | |
| refreshAll(); | |
| showSaveIndicator(); | |
| } | |
| } | |
| }; | |
| eventSource.onerror = () => { | |
| console.log('SSE: Connection lost, reconnecting...'); | |
| eventSource.close(); | |
| // Reconnect after 5 seconds | |
| setTimeout(connectSSE, 5000); | |
| }; | |
| } | |
| // Start SSE connection | |
| connectSSE(); | |
| </script> | |
| </body> | |
| </html> | |