big-screen / static /index.html
Mathias
Fix refresh button to invalidate cache before fetching
e55761d
<!DOCTYPE html>
<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) !important; }
[data-theme="dark"] th:hover { background: rgba(109, 62, 243, 0.3) !important; 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) !important; }
/* 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) !important; }
/* 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>