1import domtoimage from 'dom-to-image-more'; 2 3/** 4 * Weekly-Performance-Kachel (PV-167): zeichnet nur die Gruppen, die der 5 * Server in `totals` liefert (Gruppen ohne Liga fehlen dort). Reihenfolge 6 * spiegelt WeeklyPerformanceCalculator::GROUPS. Chopped hat eine eigene 7 * Kachelform (Ueberleben statt Bilanz); nicht gewertete Wochen zeigen einen 8 * expliziten Pending-Zustand, nie 0-0-Unentschieden. 9 */ 10export default class WeeklyPerformanceCard { 11 static FORMATS = { 12 redraft: { label: 'Redraft' }, 13 dynasty: { label: 'Dynasty' }, 14 keeper: { label: 'Keeper' }, 15 bestball: { label: 'Bestball' }, 16 chopped: { label: 'Chopped' } 17 }; 18 19 static ORDER = ['redraft', 'dynasty', 'keeper', 'bestball', 'chopped']; 20 21 constructor(rootSelector) { 22 this.root = document.querySelector(rootSelector); 23 if (!this.root) return; 24 25 this.body = this.root.querySelector('#weekly-performance-body'); 26 this.weekSelector = this.root.querySelector('#weekly-week-selector'); 27 this.shareBtn = this.root.querySelector('#weekly-share-btn'); 28 29 this.currentWeek = parseInt(this.root.dataset.currentWeek, 10); 30 } 31 32 init() { 33 if (!this.root) return; 34 this.bindEvents(); 35 this.loadWeekData(this.weekSelector.value); 36 } 37 38 bindEvents() { 39 this.weekSelector.addEventListener('change', (e) => this.loadWeekData(e.target.value)); 40 this.shareBtn.addEventListener('click', () => this.shareImage()); 41 } 42 43 apiUrl(week) { 44 return this.root.dataset.url + `?week=${week}`; 45 } 46 47 async loadWeekData(week) { 48 this.body.innerHTML = `<p class="sv-text-dim">Loading â¦</p>`; 49 try { 50 const res = await fetch(this.apiUrl(week), { credentials: 'same-origin' }); 51 if (!res.ok) throw new Error(`HTTP ${res.status}`); 52 const data = await res.json(); 53 this.render(data); 54 } catch (err) { 55 this.body.innerHTML = `<div class="sv-alert sv-alert--danger">Could not load data. Your leagues may still be syncing in the background â please try again in a few minutes.</div>`; 56 } 57 } 58 59 render(data) { 60 if (!data || typeof data.totals !== 'object' || data.totals === null) { 61 this.body.innerHTML = `<div class="sv-alert sv-alert--danger">Unexpected response â no data available.</div>`; 62 return; 63 } 64 65 const groups = WeeklyPerformanceCard.ORDER.filter( 66 (g) => data.totals[g] && this.safeNum(data.totals[g].leagues) > 0 67 ); 68 69 if (groups.length === 0) { 70 this.body.innerHTML = ` 71 <div class="sv-empty"> 72 <div class="sv-empty__title">No synced leagues</div> 73 <div class="sv-empty__hint">Enable sync for at least one active league under âMy Leaguesâ to see your weekly record here.</div> 74 </div>`; 75 return; 76 } 77 78 const tiles = groups 79 .map((g) => (g === 'chopped' ? this.renderChoppedTile(data.totals[g]) : this.renderTile(g, data.totals[g]))) 80 .join(''); 81 const dense = groups.length > 3 ? ' sv-wp-grid--many' : ''; 82 this.body.innerHTML = `<div class="sv-wp-grid${dense}" style="--sv-wp-count:${groups.length};">${tiles}</div>`; 83 } 84 85 renderTile(type, stats) { 86 const win = this.safeNum(stats.win); 87 const loss = this.safeNum(stats.loss); 88 const tie = this.safeNum(stats.tie); 89 const leagues = this.safeNum(stats.leagues); 90 const pending = this.safeNum(stats.pending); 91 const games = win + loss + tie; 92 const fmt = WeeklyPerformanceCard.FORMATS[type] ?? { label: type }; 93 94 let cls, pctLabel, record; 95 if (games > 0) { 96 const pct = win / games; 97 cls = this.classifyRatio(pct).cls; 98 pctLabel = `${Math.round(pct * 100)}%`; 99 record = `${win}-${loss}-${tie}`; 100 } else { 101 cls = 'pending'; 102 pctLabel = 'â'; 103 record = pending > 0 ? 'Not scored yet' : 'No games'; 104 } 105 const p = games > 0 ? win / games : 0; 106 107 return ` 108 <div class="sv-wp-tile sv-wp-tile--${cls}"> 109 <div class="sv-wp-tile__head"> 110 <span class="sv-wp-tile__title">${fmt.label}</span> 111 <span class="sv-badge sv-badge--neutral">${leagues} ${leagues === 1 ? 'League' : 'Leagues'}</span> 112 </div> 113 114 <div class="sv-wp-gauge" style="--p:${p};"> 115 <div class="sv-wp-gauge__label">${pctLabel}</div> 116 <div class="sv-wp-gauge__sub">${record}</div> 117 </div> 118 119 <div class="sv-wp-pills">
120 <span class="sv-badge sv-badge--neutral" title="Points for â deine Punkte im Head-to-Head dieser Woche, über alle Ligen der Kachel summiert."><strong>PF</strong> ${this.safeNum(stats.pointsFor).toFixed(1)}</span> 121 <span class="sv-badge sv-badge--neutral" title="Points against â die Punkte deiner Gegner im Head-to-Head dieser Woche."><strong>PA</strong> ${this.safeNum(stats.pointsAgainst).toFixed(1)}</span> 122 ${pending > 0 && games > 0 ? `<span class="sv-badge sv-badge--neutral" title="Ligen, deren Woche Sleeper noch nicht gewertet hat.">${pending} pending</span>` : ''} 123 </div> 124 </div> 125 `; 126 } 127 128 renderChoppedTile(stats) { 129 const survived = this.safeNum(stats.survived); 130 const chopped = this.safeNum(stats.chopped); 131 const out = this.safeNum(stats.out); 132 const pending = this.safeNum(stats.pending); 133 const leagues = this.safeNum(stats.leagues); 134 const decided = survived + chopped; 135 136 // Kein Donut: die Ueberlebenszahlen brauchen Platz, den der Ring nicht hat. 137 // Stattdessen zwei KPI-Zahlen (Design-System-Molekuel) ueber dem 138 // Fortschrittsbalken als Ueberlebensanteil. 139 let cls, pct, caption, survivedValue, choppedValue; 140 if (decided > 0) { 141 pct = survived / decided; 142 cls = this.classifyRatio(pct).cls; 143 caption = `${Math.round(pct * 100)}% survived`; 144 survivedValue = String(survived); 145 choppedValue = String(chopped); 146 } else { 147 pct = 0; 148 cls = 'pending'; 149 caption = pending > 0 ? 'Not scored yet' : 'Already out'; 150 survivedValue = 'â'; 151 choppedValue = 'â'; 152 } 153 const barCls = decided === 0 ? '' : (chopped === 0 ? 'is-ok' : 'is-err'); 154 155 const margin = typeof stats.cutMargin === 'number' && !isNaN(stats.cutMargin) ? stats.cutMargin : null; 156 const marginLabel = margin === null ? 'â' : `${margin > 0 ? '+' : ''}${margin.toFixed(1)}`; 157 158 return ` 159 <div class="sv-wp-tile sv-wp-tile--${cls}"> 160 <div class="sv-wp-tile__head"> 161 <span class="sv-wp-tile__title">Chopped</span> 162 <span class="sv-badge sv-badge--neutral">${leagues} ${leagues === 1 ? 'League' : 'Leagues'}</span> 163 </div> 164 165 <div class="sv-wp-survival"> 166 <div class="sv-kpi-grid sv-wp-survival__kpis"> 167 <div class="sv-kpi sv-kpi--ok" title="Chopped-Ligen, in denen du diese Woche nicht das niedrigste Team warst."> 168 <div class="sv-kpi__value">${survivedValue}</div> 169 <div class="sv-kpi__label">Survived</div> 170 </div> 171 <div class="sv-kpi sv-kpi--err" title="Chopped-Ligen, in denen du diese Woche ausgeschieden bist."> 172 <div class="sv-kpi__value">${choppedValue}</div> 173 <div class="sv-kpi__label">Chopped</div> 174 </div> 175 </div> 176 <div class="sv-progress" role="progressbar" aria-label="Survival rate" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${Math.round(pct * 100)}"> 177 <div class="sv-progress__bar ${barCls}" style="width:${Math.round(pct * 100)}%;"></div> 178 </div> 179 <div class="sv-wp-survival__caption">${caption}</div> 180 </div> 181 182 <div class="sv-wp-pills"> 183 <span class="sv-badge sv-badge--neutral" title="Points for â deine Punkte in dieser Woche, über alle Chopped-Ligen summiert, in denen du noch dabei warst."><strong>PF</strong> ${this.safeNum(stats.pointsFor).toFixed(1)}</span> 184 <span class="sv-badge sv-badge--neutral" title="Knappster Abstand zum Beil: deine Punkte minus das niedrigste andere noch lebende Team â negativ heiÃt, du warst das niedrigste."><strong>Cut</strong> ${marginLabel}</span> 185 ${out > 0 ? `<span class="sv-badge sv-badge--neutral" title="Ligen, aus denen du schon vor dieser Woche ausgeschieden bist.">${out} out</span>` : ''} 186 ${pending > 0 && decided > 0 ? `<span class="sv-badge sv-badge--neutral" title="Ligen, deren Woche Sleeper noch nicht gewertet hat.">${pending} pending</span>` : ''} 187 </div> 188 </div> 189 `; 190 } 191 192 classifyRatio(p) { 193 // Akzentfarbe kommt aus dem Tile-Modifier (CSS), nicht inline. 194 if (p >= 0.6) return { cls: 'good' }; 195 if (p >= 0.45) return { cls: 'ok' }; 196 return { cls: 'bad' }; 197 } 198 199 safeNum(n, d = 0) { 200 return (typeof n === 'number' && !isNaN(n)) ? n : d; 201 } 202 203 async shareImage(mode = 'wide') { 204 const presets = { wide: { w: 1200, h: 630 }, tall: { w: 1080, h: 1350 } }; 205 const { w, h } = presets[mode] || presets.wide; 206 207 this.shareBtn.disabled = true; 208 const originalHtml = this.shareBtn.innerHTML; 209 this.shareBtn.textContent = 'Creating image â¦'; 210 211 const prev = { 212 width: this.root.style.width, 213 height: this.root.style.height, 214 padding: this.root.style.padding, 215 dataShare: this.root.getAttribute('data-share'), 216 hadShare: this.root.classList.contains('wp-share'), 217 }; 218 219 try { 220 this.root.classList.add('wp-share'); 221 this.root.setAttribute('data-share', mode); 222 this.root.style.width = w + 'px'; 223 this.root.style.height = h + 'px'; 224 this.root.style.padding = '24px'; 225 226 // Einen Frame warten, bis Layout stabil ist 227 await new Promise(r => requestAnimationFrame(() => r())); 228 229 const bg = 230 getComputedStyle(this.root).backgroundColor || 231 getComputedStyle(document.body).backgroundColor || '#ffffff'; 232 233 const blob = await domtoimage.toBlob(this.root, { 234 width: w, 235 height: h, 236 bgcolor: bg, 237 quality: 1, 238 cacheBust: true, 239 // WICHTIG: harte Styles auf dem Klon 240 style: { 241 width: w + 'px', 242 height: h + 'px', 243 overflow: 'hidden', 244 border: '0', 245 boxShadow: 'none', 246 margin: '0', 247 }, 248 }); 249 250 const url = URL.createObjectURL(blob); 251 const a = document.createElement('a'); 252 a.href = url; 253 a.download = `my-pocketvision-weekly-performance-${mode}.png`; 254 a.click(); 255 } catch (e) { 256 console.error(e); 257 alert('Error generating image'); 258 } finally { 259 if (!prev.hadShare) this.root.classList.remove('wp-share'); 260 if (prev.dataShare) this.root.setAttribute('data-share', prev.dataShare); 261 else this.root.removeAttribute('data-share'); 262 this.root.style.width = prev.width; 263 this.root.style.height = prev.height; 264 this.root.style.padding = prev.padding; 265 this.shareBtn.disabled = false;
266 this.shareBtn.innerHTML = originalHtml; 267 } 268 } 269 270}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.