PageSourceSearch

https://claudetuner.com/dashboard/team/team-group-analysis.js?v=20261001

js claudetuner.com collected 2026-10-01 14:03:20 UTC 19,209 bytes, 398 lines download raw bytes

1// team-group-analysis.js — Team dashboard module (classic global script, NOT an ES module).
2// Mechanically extracted from site/dashboard/team/index.html inline <script> (orig lines 7025-7367).
3// Load order & original code preserved byte-for-byte; do not reorder relative to sibling team-*.js.
4
5// === GROUP ANALYSIS TAB ===
6var _grpLoaded = false;
7var _grpPeriod = 7;
8var _grpLevel = 0;
9var _grpMinMembers = 3;
10var _grpStat = 'avg'; // 'avg' or 'median'
11var _grpEndDate = new Date().toISOString().slice(0, 10);
12var _grpData = null;
13
14async function lazyLoadGroup() {
15  if (!currentOrg) return;
16  _grpLoaded = true;
17  // URL에서 복원된 값으로 버튼 active 동기화
18  document.querySelectorAll('.grp-period').forEach(function(b) { b.classList.toggle('active', Number(b.dataset.val) === _grpPeriod); });
19  document.querySelectorAll('.grp-level').forEach(function(b) { b.classList.toggle('active', Number(b.dataset.level) === _grpLevel); });
20  document.querySelectorAll('.grp-min').forEach(function(b) { b.classList.toggle('active', Number(b.dataset.val) === _grpMinMembers); });
21  document.querySelectorAll('.grp-stat').forEach(function(b) { b.classList.toggle('active', b.dataset.val === _grpStat); });
22  var el = document.getElementById('tab-group-loading');
23  if (el) el.style.display = '';
24  try {
25    var res = await apiFetch(API_BASE + '/api/me/orgs/' + currentOrg.id + '/leaderboard?period=' + _grpPeriod + '&end=' + _grpEndDate, { headers: authHeaders() });
26    _grpData = await res.json();
27    renderGroupAnalysis();
28    updateGrpDateLabel();
29  } catch(err) { console.error('group load failed:', err); }
30  if (el) el.style.display = 'none';
31}
32
33function syncGrpUrl() {
34  updateUrlParam('grp_period', _grpPeriod, 7);
35  var today = new Date().toISOString().slice(0, 10);
36  updateUrlParam('grp_end', _grpEndDate === today ? null : _grpEndDate);
37  updateUrlParam('grp_level', _grpLevel, 0);
38  updateUrlParam('grp_min', _grpMinMembers, 3);
39  updateUrlParam('grp_stat', _grpStat, 'avg');
40}
41
42async function setGrpPeriod(days, e) {
43  _grpPeriod = days;
44  document.querySelectorAll('.grp-period').forEach(function(b) { b.classList.remove('active'); });
45  if (e && e.target) e.target.classList.add('active');
46  syncGrpUrl();
47  _grpData = null;
48  lazyLoadGroup();
49}
50
51function loadGroup() { _grpData = null; lazyLoadGroup(); }
52
53function shiftGrpDate(dir) {
54  var d = new Date(_grpEndDate + 'T00:00:00Z');
55  d.setUTCDate(d.getUTCDate() + dir * _grpPeriod);
56  var today = new Date().toISOString().slice(0, 10);
57  if (d.toISOString().slice(0, 10) > today) d = new Date(today + 'T00:00:00Z');
58  var start = new Date(d); start.setUTCDate(start.getUTCDate() - Math.max(0, _grpPeriod - 1));
59  var minStart = new Date(); minStart.setUTCDate(minStart.getUTCDate() - 90);
60  if (start.toISOString().slice(0, 10) < minStart.toISOString().slice(0, 10)) return;
61  _grpEndDate = d.toISOString().slice(0, 10);
62  syncGrpUrl();
63  loadGroup();
64}
65
66function resetGrpToday() {
67  _grpEndDate = new Date().toISOString().slice(0, 10);
68  syncGrpUrl();
69  loadGroup();
70}
71
72function updateGrpDateLabel() {
73  var end = new Date(_grpEndDate + 'T00:00:00Z');
74  var start = new Date(end);
75  start.setUTCDate(start.getUTCDate() - Math.max(0, _grpPeriod - 1));
76  var label = document.getElementById('grp-date-label');
77  if (label) label.textContent = start.toISOString().slice(0, 10) + '  ~  ' + end.toISOString().slice(0, 10);
78  var isToday = _grpEndDate >= new Date().toISOString().slice(0, 10);
79  var nextBtn = document.getElementById('grp-date-next');
80  if (nextBtn) nextBtn.disabled = isToday;
81  var todayBtn = document.getElementById('grp-date-today');
82  if (todayBtn) todayBtn.disabled = isToday;
83}
84
85function setGrpLevel(level, e) {
86  _grpLevel = level;
87  document.querySelectorAll('.grp-level').forEach(function(b) { b.classList.remove('active'); });
88  if (e && e.target) e.target.classList.add('active');
89  syncGrpUrl();
90  renderGroupAnalysis();
91}
92
93function setGrpMin(n, e) {
94  _grpMinMembers = n;
95  document.querySelectorAll('.grp-min').forEach(function(b) { b.classList.remove('active'); });
96  if (e && e.target) e.target.classList.add('active');
97  syncGrpUrl();
98  renderGroupAnalysis();
99}
100
101function setGrpStat(stat, e) {
102  _grpStat = stat;
103  document.querySelectorAll('.grp-stat').forEach(function(b) { b.classList.remove('active'); });
104  if (e && e.target) e.target.classList.add('active');
105  syncGrpUrl();
106  renderGroupAnalysis();
107}
108
109function goToLbWithFilter(teamFilter) {
110  var params = new URLSearchParams();
111  params.set('tab', 'leaderboard');
112  params.set('team', teamFilter);
113  params.set('lb_period', String(_grpPeriod));
114  var today = new Date().toISOString().slice(0, 10);
115  if (_grpEndDate !== today) params.set('lb_end', _grpEndDate);
116  window.open(location.pathname + '?' + params.toString(), '_blank');
117}
118
119function renderGroupAnalysis() {
120  var container = document.getElementById('group-analysis-content');
121  if (!container || !_grpData) return;
122  var lb = _grpData.leaderboard || [];
123
124  // Provider scope (shared scope bar): 'all' uses each member's top-level totals; a specific provider
125  // uses that member's by_provider[pf] tokens/active_days (already delivered in the leaderboard payload),
126  // and members with no usage on the selected service are dropped — same rule as leaderboard/ranking.
127  var pf = (typeof _teamProvFilter !== 'undefined') ? _teamProvFilter : 'all';
128  // Clamp to 'all' if the (older/transition) payload has no by_provider at all — else every member would
129  // be dropped and all groups would render empty. Mirrors the leaderboard's local provider clamp.
130  if (pf !== 'all' && !lb.some(function(e) { return e && e.by_provider; })) pf = 'all';
131
132  // 그룹별 집계 (팀 필터 + 프로바이더 스코프 반영)
133  // Per-person daily average is ADDITIVE across services: for '전체' it equals the SUM of each service's
134  // group daily average — Σ_p (그룹 tokens_p / 그룹 active_days_p) — so '전체' = Claude값 + ChatGPT값
135  // (+ Gemini), matching the per-provider tabs and the Total column (which already sum providers). We
136  // therefore accumulate per-provider (tokens, active_days) sums per group, NOT a single combined
137  // total/union-days pair (that would blend services and read as an average, not a sum).
138  var GRP_PROVS = ['claude', 'chatgpt', 'gemini'];
139  var groups = {};
140  for (var i = 0; i < lb.length; i++) {
141    var entry = lb[i];
142    if (_currentTeamFilter && !passesTeamFilter(entry.user_email)) continue;
143    var bp = (pf !== 'all' && entry.by_provider) ? entry.by_provider[pf] : null;
144    var eTok = (pf === 'all') ? (entry.est_tokens || 0) : ((bp && bp.est_tokens) || 0);
145    if (pf !== 'all' && eTok <= 0) continue; // member has no usage on the selected service
146
147    // Per-service (tokens, days) contributions for this member under the active scope.
148    var contribs = [];
149    if (pf === 'all') {
150      if (entry.by_provider) {
151        for (var pi = 0; pi < GRP_PROVS.length; pi++) {
152          var pv = entry.by_provider[GRP_PROVS[pi]];
153          if (pv && ((pv.est_tokens || 0) > 0 || (pv.active_days || 0) > 0)) contribs.push({ p: GRP_PROVS[pi], tok: pv.est_tokens || 0, days: pv.active_days || 0 });
154        }
155      }
156      // Fallback for older payloads with no by_provider: attribute the combined total to one bucket.
157      if (contribs.length === 0) contribs.push({ p: 'claude', tok: entry.est_tokens || 0, days: entry.active_days || 0 });
158    } else {
159      contribs.push({ p: pf, tok: (bp && bp.est_tokens) || 0, days: (bp && bp.active_days) || 0 });
160    }
161
162    var team = getTeamForEmail(entry.user_email) || '';
163    var groupKey;
164    if (_grpLevel === 0) {
165      groupKey = team ? team.split('/')[0] : t('grp_unassigned') || '(미지정)';
166    } else {
167      groupKey = team || t('grp_unassigned') || '(미지정)';
168    }
169    if (!groups[groupKey]) groups[groupKey] = { name: groupKey, totalTokens: 0, members: 0, byProv: { claude: { tok: 0, days: 0 }, chatgpt: { tok: 0, days: 0 }, gemini: { tok: 0, days: 0 } }, memDailies: [] };
170    var gAcc = groups[groupKey];
171    gAcc.members += 1;
172    var memTok = 0, memDaily = 0, memHasDays = false;
173    for (var ci = 0; ci < contribs.length; ci++) {
174      var c = contribs[ci];
175      gAcc.byProv[c.p].tok += c.tok;
176      gAcc.byProv[c.p].days += c.days;
177      memTok += c.tok;
178      if (c.days > 0) { memDaily += Math.round(c.tok / c.days); memHasDays = true; } // member's sum of per-service daily averages
179    }
180    gAcc.totalTokens += memTok;
181    // Include any active member in the median input, even one whose daily value is 0 (e.g. a Gemini-only
182    // member: active_days>0 but est_tokens~0) — dropping them would bias the median upward.
183    if (memHasDays) gAcc.memDailies.push(memDaily);
184  }
185
186  // 배열로 변환
187  var rows = [];
188  for (var key in groups) {
189    var g = groups[key];
190    // avg = sum of per-service group daily averages. Round EACH service's tokens/days (exactly what its
191    // own provider tab displays), THEN sum — so '전체' equals the manual sum of the per-provider tabs to
192    // the token. For a single provider only that bucket has data, so this reduces to the tab's value.
193    var avg = 0;
194    for (var gp = 0; gp < GRP_PROVS.length; gp++) {
195      var b = g.byProv[GRP_PROVS[gp]];
196      if (b.days > 0) avg += Math.round(b.tok / b.days);
197    }
198    var sorted = g.memDailies.slice().sort(function(a, b) { return a - b; });
199    var med = 0;
200    if (sorted.length > 0) {
201      var mid = Math.floor(sorted.length / 2);
202      med = sorted.length % 2 === 0 ? Math.round((sorted[mid - 1] + sorted[mid]) / 2) : sorted[mid];
203    }
204    rows.push({
205      name: g.name,
206      members: g.members,
207      perPerson: _grpStat === 'median' ? med : avg,
208      avg: avg,
209      median: med,
210      totalTokens: g.totalTokens,
211    });
212  }
213  if (_grpMinMembers > 0) rows = rows.filter(function(r) { return r.members >= _grpMinMembers; });
214  rows.sort(function(a, b) { return b.perPerson - a.perPerson; });
215
216  var maxPP = rows.length > 0 ? rows[0].perPerson : 1;
217
218  // HTML 렌더링 (리더보드와 유사한 테이블)
219  var html = '<table style="width:100%;border-collapse:collapse;font-size:13px">';
220  html += '<thead><tr style="border-bottom:2px solid var(--gray-200);font-size:11px;color:var(--gray-400)">';
221  html += '<th style="text-align:left;padding:8px 4px;font-weight:500">#</th>';
222  html += '<th style="text-align:left;padding:8px 4px;font-weight:500">' + (t('grp_level' + _grpLevel) || 'Group') + '</th>';
223  html += '<th style="text-align:right;padding:8px 4px;font-weight:500">' + t('grp_members') + '</th>';
224  html += '<th style="text-align:left;padding:8px 4px 8px 12px;font-weight:500;width:40%">' + t('grp_per_person') + '</th>';
225  html += '<th style="text-align:right;padding:8px 4px;font-weight:500">' + t('grp_total') + '</th>';
226  html += '</tr></thead><tbody>';
227
228  for (var ri = 0; ri < rows.length; ri++) {
229    var r = rows[ri];
230    var barPct = maxPP > 0 ? Math.max(2, Math.round(r.perPerson / maxPP * 100)) : 0;
231    var isTop = ri < 3;
232    var rankLabel = ri === 0 ? '🥇' : ri === 1 ? '🥈' : ri === 2 ? '🥉' : String(ri + 1);
233    html += '<tr style="border-bottom:1px solid var(--gray-100)">';
234    html += '<td style="padding:10px 4px;' + (isTop ? 'font-size:16px' : 'font-size:11px;color:var(--gray-400)') + '">' + rankLabel + '</td>';
235    var nameParts = r.name.split('/');
236    var nameHtml = '';
237    for (var ni = 0; ni < nameParts.length; ni++) {
238      var filterVal = nameParts.slice(0, ni + 1).join('/');
239      if (ni > 0) nameHtml += '<span style="color:var(--gray-300);margin:0 2px">/</span>';
240      nameHtml += '<span style="color:#5b21b6;cursor:pointer;text-decoration:underline;text-decoration-style:dotted;text-underline-offset:2px" onclick="event.stopPropagation();goToLbWithFilter(\'' + escHtml(filterVal) + '\')">' + escHtml(nameParts[ni]) + '</span>';
241    }
242    html += '<td style="padding:10px 4px;font-weight:600">' + nameHtml + '</td>';
243    html += '<td style="padding:10px 4px;text-align:right;color:var(--gray-500);font-size:12px">' + r.members + '</td>';
244    html += '<td style="padding:10px 4px 10px 12px">';
245    html += '<div style="display:flex;align-items:center;gap:8px">';
246    html += '<div style="flex:1;background:var(--gray-100);border-radius:4px;
246height:8px;min-width:60px"><div style="background:#7c3aed;height:8px;border-radius:4px;width:' + barPct + '%"></div></div>';
247    html += '<span style="font-size:12px;font-weight:600;white-space:nowrap;min-width:50px;text-align:right">' + fmtTokens(r.perPerson) + '</span>';
248    html += '</div></td>';
249    html += '<td style="padding:10px 4px;text-align:right;color:var(--gray-500);font-size:12px">' + fmtTokens(r.totalTokens) + '</td>';
250    html += '</tr>';
251  }
252  html += '</tbody></table>';
253
254  if (_orgTeams.length === 0) {
255    html = '<div style="text-align:center;padding:60px 24px">'
256      + '<div style="font-size:40px;margin-bottom:12px">📂</div>'
257      + '<div style="font-size:15px;color:var(--gray-500);font-weight:600;margin-bottom:8px">' + t('grp_no_groups_title') + '</div>'
258      + '<div style="font-size:13px;color:var(--gray-400);margin-bottom:16px">' + t('grp_no_groups_desc') + '</div>'
259      + '<button class="btn btn-primary" onclick="switchTab(\'settings\')" style="font-size:13px">' + t('grp_go_settings') + '</button>'
260      + '</div>';
261  } else if (rows.length === 0) {
262    html = '<div style="text-align:center;padding:40px;color:var(--gray-400)">' + t('no_data') + '</div>';
263  }
264
265  container.innerHTML = html;
266}
267
268// === CSV export ===
269async function exportCSV() {
270  try {
271    var res = await apiFetch(API_BASE + '/api/me/orgs/' + currentOrg.id + '/export?format=csv', { headers: authHeaders() });
272    var text = await res.text();
273    var blob = new Blob([text], { type: 'text/csv' });
274    var url = URL.createObjectURL(blob);
275    var a = document.createElement('a');
276    a.href = url; a.download = (currentOrg.name || 'org') + '-members.csv';
277    a.click(); URL.revokeObjectURL(url);
278  } catch(e) { alert(t('err_csv')); }
279}
280
281// === Tab switch ===
282function updateUrlParam(key, value, defaultVal) {
283  var params = new URLSearchParams(location.search);
284  if (value === defaultVal || value === null || value === undefined) params.delete(key);
285  else params.set(key, String(value));
286  var qs = params.toString();
287  history.replaceState(null, '', location.pathname + (qs ? '?' + qs : ''));
288}
289
290function switchTab(tab) {
291  // Soft-launch gate: block direct entry to '서비스별 시트' (e.g. ?tab=provseats in the URL) for
292  // anyone outside the allowlist, so they don't land on a tab whose endpoint will 403.
293  if (tab === 'provseats' && SEATS_ALLOWLIST.indexOf(currentUserEmail) < 0) tab = 'overview';
294  var tabs = ['overview', 'costs', 'breaches', 'leaderboard', 'group', 'members', 'settings', 'activity', 'ranking', 'provseats'];
295  for (var i = 0; i < tabs.length; i++) {
296    var el = document.getElementById('tab-' + tabs[i]);
297    if (el) el.style.display = tabs[i] === tab ? '' : 'none';
298  }
299  document.querySelectorAll('.team-tab-btn[data-tab]').forEach(function(btn) {
300    btn.classList.toggle('active', btn.dataset.tab === tab);
301  });
302  // The tab row is one horizontally scrolling line on narrow screens (.team-tab-row) — keep the
303  // active tab in view. Scrolls the row only, never the page.
304  var activeTabBtn = document.querySelector('.team-tab-row .team-tab-btn.active');
305  if (activeTabBtn) {
306    var tabRow = activeTabBtn.parentElement;
307    var btnL = activeTabBtn.offsetLeft, btnR = btnL + activeTabBtn.offsetWidth;
308    if (btnL < tabRow.scrollLeft) tabRow.scrollLeft = btnL;
309    else if (btnR > tabRow.scrollLeft + tabRow.clientWidth) tabRow.scrollLeft = btnR - tabRow.clientWidth;
310  }
311  // Update URL query string (without reload)
312  var params = new URLSearchParams(location.search);
313  if (tab === 'overview') params.delete('tab');
314  else params.set('tab', tab);
315  var qs = params.toString();
316  history.replaceState(null, '', location.pathname + (qs ? '?' + qs : ''));
317  // Show/hide + refresh the shared provider scope bar for this tab (state preserved while hidden).
318  if (typeof refreshTeamProvScope === 'function') refreshTeamProvScope(tab);
319  // A provider-aware tab whose shared filter changed while it was hidden re-renders from cache here.
320  if (_provDirty[tab]) { _provDirty[tab] = false; if (typeof _applyProvFilterToTab === 'function') _applyProvFilterToTab(tab); }
321  // Lazy load tab data on first switch
322  if (tab === 'costs' && !_costsLoaded) lazyLoadCosts();
323  if (tab === 'breaches' && !_breachLoaded) lazyLoadBreaches();
324  if (tab === 'leaderboard' && !_lbLoaded) lazyLoadLeaderboard();
325  if (tab === 'group' && !_grpLoaded) lazyLoadGroup();
326  if (tab === 'activity' && !_actLoaded) actLoadChart(_actDate);
327  if (tab === 'settings' && !_orderHistoryLoaded) loadOrderHistory();
328  if (tab === 'ranking' && !_rankLoaded) lazyLoadRanking();
329  if (tab === 'provseats' && !_seatsLoaded) lazyLoadProviderSeats();
330  // Resize ECharts in newly visible tab
331  setTimeout(function() {
332    var tabEl = document.getElementById('tab-' + tab);
333    if (tabEl) tabEl.querySelectorAll('[_echarts_instance_]').forEach(function(div) {
334      var inst = echarts.getInstanceByDom(div);
335      if (inst) inst.resize();
336    });
337  }, 50);
338}
339
340function switchOrg(orgId) {
341  var org = currentOrgList.find(function(o) { return String(o.id) === String(orgId); });
342  if (org) selectOrg(org);
343}
344
345// === Member search filter ===
346// === Sortable table ===
347function sortTable(th) {
348  var table = th.closest('table');
349  var tbody = table.querySelector('tbody');
350  var rows = Array.prototype.slice.call(tbody.querySelectorAll('tr'));
351  var colIdx = Array.prototype.indexOf.call(th.parentNode.children, th);
352  var isAsc = th.classList.contains('sort-asc');
353
354  // Reset all headers
355  table.querySelectorAll('th.sortable').forEach(function(h) {
356    h.classList.remove('sort-asc', 'sort-desc');
357    var icon = h.querySelector('.sort-icon');
358    if (icon) icon.textContent = '\u25B2';
359  });
360
361  // Toggle direction
362  if (isAsc) {
363    th.classList.add('sort-desc');
364    th.querySelector('.sort-icon').textContent = '\u25BC';
365  } else {
366    th.classList.add('sort-asc');
367    th.querySelector('.sort-icon').textContent = '\u25B2';
368  }
369  var dir = isAsc ? -1 : 1;
370
371  rows.sort(function(a, b) {
372    var cellA = a.children[colIdx];
373    var cellB = b.children[colIdx];
374    var va = cellA.getAttribute('data-sort') || cellA.textContent.trim();
375    var vb = cellB.getAttribute('data-sort') || cellB.textContent.trim();
376
377    // Try numeric sort (only if the whole string is numeric)
378    var na = Number(va), nb = Number(vb);
379    if (va !== '' && vb !== '' && !isNaN(na) && !isNaN(nb)) return (na - nb) * dir;
380
381    // String sort (works for ISO dates, text, etc.)
382    return va.localeCompare(vb) * dir;
383  });
384
385  for (var i = 0; i < rows.length; i++) {
386    tbody.appendChild(rows[i]);
387  }
388}
389
390function filterMembers() {
391  var q = document.getElementById('member-search').value.toLowerCase();
392  document.querySelectorAll('#members-list .member-card').forEach(function(card) {
393    var name = card.dataset.name || '';
394    var email = card.dataset.email || '';
395    card.style.display = (name.indexOf(q) >= 0 || email.indexOf(q) >= 0) ? '' : 'none';
396  });
397}
398

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.