AtCoder Multi Tracker

Track up to 5 users with stable UI and no overlap

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

Advertisement:

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

Advertisement:

// ==UserScript==
// @name         AtCoder Multi Tracker
// @name:ja AtCoder Multi Tracker
// @description:ja 五人までのユーザーの提出状況を確認できます。
// @license MIT
// @namespace    https://github.com/yourname
// @version      1.4.0
// @description  Track up to 5 users with stable UI and no overlap
// @match        https://atcoder.jp/contests/*
// @grant        none
// @run-at       document-end
// ==/UserScript==

(() => {
  'use strict';

  if (window.top !== window.self) return;

  const MAX_USERS = 5;
  const STORAGE_USERS_KEY = 'acmt:users:v4';
  const STORAGE_SNAPSHOT_KEY = (contestId) => `acmt:snapshot:v4:${contestId}`;

  const UPDATE_INTERVAL_MS = 30_000;
  const BASE_BOTTOM = 14;
  const GAP = 10;

  const contestId = location.pathname.match(/^\/contests\/([^/]+)/)?.[1];
  if (!contestId) return;

  const state = {
    users: loadUsers(),
    snapshot: loadSnapshot(contestId),
    open: false,
    refreshing: false,
    refreshTimer: null,
    refreshToken: 0,
  };

  injectStyles();

  const root = document.createElement('div');
  root.id = 'acmt-root';
  document.body.appendChild(root);

  const dock = createDock();
  const modal = createSettingsModal();

  root.appendChild(dock);
  root.appendChild(modal);

  const launcher = dock.querySelector('[data-acmt="launcher"]');
  const panelBody = dock.querySelector('[data-acmt="panel-body"]');
  const panelStatus = dock.querySelector('[data-acmt="panel-status"]');
  const launcherLabel = dock.querySelector('[data-acmt="launcher-label"]');
  const launcherCount = dock.querySelector('[data-acmt="launcher-count"]');

  const modalBackdrop = modal.querySelector('[data-acmt="modal-backdrop"]');
  const modalInputs = modal.querySelector('[data-acmt="modal-inputs"]');
  const modalTitle = modal.querySelector('[data-acmt="modal-title"]');

  launcher.addEventListener('click', () => {
    if (state.users.length === 0) {
      openSettings();
      return;
    }
    toggleDock();
  });

  dock.querySelector('[data-acmt="settings-button"]').addEventListener('click', (e) => {
    e.stopPropagation();
    openSettings();
  });

  dock.querySelector('[data-acmt="refresh-button"]').addEventListener('click', (e) => {
    e.stopPropagation();
    void refresh(true);
  });

  dock.querySelector('[data-acmt="close-button"]').addEventListener('click', (e) => {
    e.stopPropagation();
    closeDock();
  });

  modalBackdrop.addEventListener('click', closeSettings);
  modal.querySelector('[data-acmt="modal-save"]').addEventListener('click', saveSettingsFromModal);
  modal.querySelector('[data-acmt="modal-cancel"]').addEventListener('click', closeSettings);

  window.addEventListener('keydown', (e) => {
    if (e.key === 'Escape') {
      if (modal.classList.contains('open')) {
        closeSettings();
        return;
      }
      if (state.open) closeDock();
    }
  });

  window.addEventListener('resize', syncDockPosition, { passive: true });
  window.addEventListener('scroll', syncDockPosition, { passive: true });
  setInterval(syncDockPosition, 500);

  renderLauncher();
  renderPanelFromSnapshot(state.snapshot, false);
  syncDockPosition();

  if (state.users.length > 0) {
    setHeaderStatus(state.snapshot ? 'Cached' : 'No cache');
  } else {
    setHeaderStatus('No users configured');
  }

  function loadUsers() {
    try {
      const raw = localStorage.getItem(STORAGE_USERS_KEY);
      if (!raw) return [];
      const parsed = JSON.parse(raw);
      if (!Array.isArray(parsed)) return [];
      return parsed
        .map(String)
        .map((s) => s.trim())
        .filter(Boolean)
        .slice(0, MAX_USERS);
    } catch {
      return [];
    }
  }

  function saveUsers() {
    try {
      localStorage.setItem(STORAGE_USERS_KEY, JSON.stringify(state.users));
    } catch {
      // ignore
    }
  }

  function loadSnapshot(id) {
    try {
      const raw = localStorage.getItem(STORAGE_SNAPSHOT_KEY(id));
      if (!raw) return null;
      const parsed = JSON.parse(raw);
      if (!parsed || typeof parsed !== 'object') return null;
      return parsed;
    } catch {
      return null;
    }
  }

  function saveSnapshot(id, snapshot) {
    try {
      localStorage.setItem(STORAGE_SNAPSHOT_KEY(id), JSON.stringify(snapshot));
    } catch {
      // ignore
    }
  }

  function injectStyles() {
    const style = document.createElement('style');
    style.textContent = `
      #acmt-root {
        position: static;
        z-index: 2147483647;
      }

      #acmt-dock {
        position: fixed;
        left: 14px;
        bottom: 14px;
        width: 160px;
        max-height: 48px;
        overflow: hidden;
        border-radius: 999px;
        background: rgba(255,255,255,.92);
        backdrop-filter: blur(14px);
        -webkit-backdrop-filter: blur(14px);
        box-shadow: 0 8px 24px rgba(0,0,0,.16);
        transition:
          width .42s cubic-bezier(.2,1.2,.2,1),
          max-height .42s cubic-bezier(.2,1.2,.2,1),
          bottom .18s ease,
          border-radius .42s cubic-bezier(.2,1.2,.2,1),
          box-shadow .22s ease,
          transform .42s cubic-bezier(.2,1.2,.2,1);
        transform-origin: left bottom;
        font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
        color: #111;
        user-select: none;
        z-index: 2147483647;
      }

      #acmt-dock.open {
        width: min(420px, calc(100vw - 28px));
        max-height: 70vh;
        border-radius: 18px;
        box-shadow: 0 18px 48px rgba(0,0,0,.24);
      }

      #acmt-dock.spring-open {
        animation: acmt-spring-open 460ms cubic-bezier(.16,1,.3,1);
      }

      @keyframes acmt-spring-open {
        0%   { transform: scale(.985); }
        55%  { transform: scale(1.012); }
        78%  { transform: scale(.998); }
        100% { transform: scale(1); }
      }

      .acmt-launcher {
        width: 100%;
        height: 48px;
        border: 0;
        background: transparent;
        color: inherit;
        cursor: pointer;
        display: flex;
        align-items: center;
        justify-content: space-between;
        gap: 10px;
        padding: 0 14px;
        box-sizing: border-box;
        font-size: 13px;
        font-weight: 800;
        text-align: left;
      }

      .acmt-launcher-left {
        display: flex;
        align-items: center;
        gap: 8px;
        min-width: 0;
      }

      .acmt-launcher-label {
        white-space: nowrap;
      }

      .acmt-launcher-badge {
        display: inline-flex;
        align-items: center;
        justify-content: center;
        min-width: 22px;
        height: 22px;
        padding: 0 7px;
        border-radius: 999px;
        background: rgba(0,0,0,.06);
        color: #333;
        font-size: 12px;
        font-weight: 800;
      }

      .acmt-launcher-chevron {
        color: rgba(0,0,0,.55);
        font-size: 16px;
        line-height: 1;
        transform: translateY(-1px);
        transition: transform .22s ease, opacity .22s ease;
      }

      #acmt-dock.open .acmt-launcher-chevron {
        transform: translateY(-1px) rotate(90deg);
      }

      .acmt-panel {
        opacity: 0;
        transform: translateY(12px) scale(.985);
        pointer-events: none;
        transition:
          opacity .22s ease,
          transform .22s cubic-bezier(.2,.9,.2,1);
      }

      #acmt-dock.open .acmt-panel {
        opacity: 1;
        transform: translateY(0) scale(1);
        pointer-events: auto;
      }

      .acmt-panel-shell {
        border-top: 1px solid rgba(0,0,0,.06);
      }

      .acmt-panel-header {
        display: flex;
        align-items: flex-start;
        justify-content: space-between;
        gap: 12px;
        padding: 12px 12px 10px 14px;
      }

      .acmt-header-left {
        min-width: 0;
      }

      .acmt-title {
        font-size: 14px;
        font-weight: 900;
        letter-spacing: .01em;
      }

      .acmt-status {
        margin-top: 4px;
        font-size: 12px;
        color: rgba(0,0,0,.60);
      }

      .acmt-header-actions {
        display: inline-flex;
        gap: 8px;
        flex-shrink: 0;
      }

      .acmt-icon-btn {
        appearance: none;
        border: 1px solid rgba(0,0,0,.08);
        background: rgba(255,255,255,.72);
        color: #111;
        border-radius: 10px;
        padding: 7px 10px;
        font-size: 12px;
        font-weight: 800;
        cursor: pointer;
        transition: transform .16s ease, background .16s ease, opacity .16s ease;
      }

      .acmt-icon-btn:hover {
        transform: translateY(-1px);
        background: rgba(255,255,255,.92);
      }

      .acmt-body {
        overflow: auto;
        padding: 0 12px 12px;
        min-height: 360px;
      }

      .acmt-empty {
        margin: 0 2px 2px;
        padding: 18px 14px;
        border-radius: 14px;
        border: 1px dashed rgba(0,0,0,.12);
        background: rgba(255,255,255,.72);
        color: rgba(0,0,0,.64);
        font-size: 13px;
        line-height: 1.55;
      }

      .acmt-card {
        margin-bottom: 10px;
        padding: 12px 12px 11px;
        border-radius: 16px;
        border: 1px solid rgba(0,0,0,.08);
        background: linear-gradient(135deg, rgba(255,255,255,.98) 0%, rgba(255,255,255,.95) 100%);
        box-shadow: 0 6px 18px rgba(0,0,0,.06);
        min-height: 96px;
      }

      .acmt-card-top {
        display: flex;
        align-items: flex-start;
        justify-content: space-between;
        gap: 12px;
      }

      .acmt-user-name {
        font-size: 14px;
        font-weight: 900;
        word-break: break-word;
      }

      .acmt-badges {
        display: flex;
        flex-wrap: wrap;
        justify-content: flex-end;
        gap: 6px;
      }

      .acmt-pill {
        display: inline-flex;
        align-items: center;
        padding: 4px 8px;
        border-radius: 999px;
        border: 1px solid rgba(0,0,0,.08);
        background: rgba(0,0,0,.03);
        font-size: 12px;
        font-weight: 800;
        white-space: nowrap;
      }

      .acmt-meta {
        margin-top: 8px;
        font-size: 12px;
        color: rgba(0,0,0,.64);
        min-height: 16px;
      }

      .acmt-chip-wrap {
        display: flex;
        flex-wrap: wrap;
        gap: 6px;
        margin-top: 9px;
        min-height: 30px;
      }

      .acmt-chip {
        display: inline-flex;
        align-items: center;
        justify-content: center;
        min-width: 24px;
        padding: 4px 8px;
        border-radius: 999px;
        border: 1px solid rgba(0,0,0,.08);
        background: rgba(255,255,255,.92);
        font-size: 12px;
        font-weight: 800;
        color: #111;
      }

      .acmt-footer {
        margin-top: 2px;
        padding-top: 10px;
        border-top: 1px solid rgba(0,0,0,.06);
        font-size: 12px;
        color: rgba(0,0,0,.56);
        min-height: 20px;
      }

      .acmt-skeleton-list {
        display: grid;
        gap: 10px;
      }

      .acmt-skeleton-card {
        min-height: 96px;
        padding: 12px 12px 11px;
        border-radius: 16px;
        border: 1px solid rgba(0,0,0,.06);
        background: linear-gradient(90deg, rgba(0,0,0,.04) 0%, rgba(0,0,0,.08) 50%, rgba(0,0,0,.04) 100%);
        background-size: 200% 100%;
        animation: acmt-shimmer 1.3s ease-in-out infinite;
      }

      .acmt-skeleton-line {
        height: 12px;
        border-radius: 999px;
        background: rgba(0,0,0,.08);
      }

      .acmt-skeleton-title {
        width: 48%;
        height: 14px;
      }

      .acmt-skeleton-meta {
        width: 72%;
        margin-top: 8px;
      }

      .acmt-skeleton-chips {
        display: flex;
        flex-wrap: wrap;
        gap: 6px;
        margin-top: 10px;
      }

      .acmt-skeleton-chip {
        width: 36px;
        height: 24px;
        border-radius: 999px;
        background: rgba(0,0,0,.07);
      }

      @keyframes acmt-shimmer {
        0% { background-position: 200% 0; }
        100% { background-position: -200% 0; }
      }

      #acmt-settings-modal {
        position: fixed;
        inset: 0;
        opacity: 0;
        pointer-events: none;
        transition: .2s;
        z-index: 2147483647;
      }

      #acmt-settings-modal.open {
        opacity: 1;
        pointer-events: auto;
      }

      .acmt-modal-backdrop {
        position: absolute;
        inset: 0;
        background: rgba(0,0,0,.28);
        backdrop-filter: blur(4px);
        -webkit-backdrop-filter: blur(4px);
      }

      .acmt-modal {
        position: absolute;
        bottom: 20px;
        left: 50%;
        transform: translateX(-50%) scale(.98);
        width: min(420px, calc(100vw - 28px));
        background: #fff;
        border-radius: 16px;
        padding: 14px;
        box-shadow: 0 18px 48px rgba(0,0,0,.22);
        transition: transform .22s cubic-bezier(.2,.9,.2,1), opacity .22s ease;
        font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
      }

      #acmt-settings-modal.open .acmt-modal {
        transform: translateX(-50%) scale(1);
      }

      .acmt-modal-title {
        font-weight: 900;
        margin-bottom: 10px;
      }

      .acmt-inputs {
        display: grid;
        gap: 10px;
      }

      .acmt-inputs input {
        width: 100%;
        box-sizing: border-box;
        border-radius: 12px;
        border: 1px solid rgba(0,0,0,.10);
        background: rgba(255,255,255,.96);
        padding: 10px 12px;
        font-size: 13px;
        outline: none;
      }

      .acmt-inputs input:focus {
        border-color: rgba(0,122,255,.45);
        box-shadow: 0 0 0 3px rgba(0,122,255,.10);
      }

      .acmt-modal-actions {
        margin-top: 12px;
        display: flex;
        justify-content: flex-end;
        gap: 8px;
      }

      .acmt-action-btn {
        appearance: none;
        border: 1px solid rgba(0,0,0,.10);
        border-radius: 12px;
        padding: 9px 12px;
        font-size: 13px;
        font-weight: 800;
        cursor: pointer;
        background: rgba(255,255,255,.96);
        color: #111;
      }

      .acmt-action-btn.primary {
        border-color: rgba(0,122,255,.26);
        background: rgba(0,122,255,.96);
        color: #fff;
      }
    `;
    document.head.appendChild(style);
  }

  function createDock() {
    const el = document.createElement('div');
    el.id = 'acmt-dock';
    el.innerHTML = `
      <button class="acmt-launcher" data-acmt="launcher" type="button">
        <span class="acmt-launcher-left">
          <span class="acmt-launcher-label" data-acmt="launcher-label">Tracker</span>
          <span class="acmt-launcher-badge" data-acmt="launcher-count">0</span>
        </span>
        <span class="acmt-launcher-chevron">›</span>
      </button>

      <div class="acmt-panel" data-acmt="panel">
        <div class="acmt-panel-shell">
          <div class="acmt-panel-header">
            <div class="acmt-header-left">
              <div class="acmt-title">AtCoder Tracker</div>
              <div class="acmt-status" data-acmt="panel-status">Idle</div>
            </div>

            <div class="acmt-header-actions">
              <button class="acmt-icon-btn" type="button" data-acmt="refresh-button">Refresh</button>
              <button class="acmt-icon-btn" type="button" data-acmt="settings-button">Settings</button>
              <button class="acmt-icon-btn" type="button" data-acmt="close-button">Close</button>
            </div>
          </div>
          <div class="acmt-body" data-acmt="panel-body"></div>
        </div>
      </div>
    `;
    return el;
  }

  function createSettingsModal() {
    const el = document.createElement('div');
    el.id = 'acmt-settings-modal';
    el.innerHTML = `
      <div class="acmt-modal-backdrop" data-acmt="modal-backdrop"></div>
      <div class="acmt-modal">
        <div class="acmt-modal-title" data-acmt="modal-title">Settings</div>
        <div class="acmt-inputs" data-acmt="modal-inputs"></div>
        <div class="acmt-modal-actions">
          <button class="acmt-action-btn" type="button" data-acmt="modal-cancel">Cancel</button>
          <button class="acmt-action-btn primary" type="button" data-acmt="modal-save">Save</button>
        </div>
      </div>
    `;
    return el;
  }

  function setHeaderStatus(text) {
    panelStatus.textContent = text;
  }

  function renderLauncher() {
    launcherLabel.textContent = state.users.length > 0 ? 'Tracker' : 'Settings';
    launcherCount.textContent = String(state.users.length);
    syncDockPosition();
  }

  function openDock() {
    if (state.open) return;

    state.open = true;
    dock.classList.add('open');
    dock.classList.remove('spring-open');

    void dock.offsetWidth;
    dock.classList.add('spring-open');
    window.setTimeout(() => dock.classList.remove('spring-open'), 480);

    renderPanelFromSnapshot(state.snapshot, true);
    setHeaderStatus(state.snapshot ? 'Updating…' : 'Loading…');
    startAutoRefresh();
    void refresh(true);
    syncDockPosition();
  }

  function closeDock() {
    if (!state.open) return;
    state.open = false;
    dock.classList.remove('open');
    dock.classList.remove('spring-open');
    stopAutoRefresh();
    syncDockPosition();
  }

  function toggleDock() {
    if (state.open) closeDock();
    else openDock();
  }

  function openSettings() {
    modalTitle.textContent = 'Settings';
    modalInputs.innerHTML = '';

    for (let i = 0; i < MAX_USERS; i++) {
      const input = document.createElement('input');
      input.type = 'text';
      input.autocomplete = 'off';
      input.spellcheck = false;
      input.placeholder = 'Enter user name';
      input.value = state.users[i] || '';
      modalInputs.appendChild(input);
    }

    modal.classList.add('open');
  }

  function closeSettings() {
    modal.classList.remove('open');
  }

  function saveSettingsFromModal() {
    state.users = [...modalInputs.querySelectorAll('input')]
      .map((i) => i.value.trim())
      .filter(Boolean)
      .slice(0, MAX_USERS);

    saveUsers();
    closeSettings();
    renderLauncher();
    renderPanelFromSnapshot(state.snapshot, false);

    if (state.open) {
      setHeaderStatus(state.snapshot ? 'Updating…' : 'Loading…');
      void refresh(true);
    } else {
      setHeaderStatus(state.snapshot ? 'Cached' : 'No cache');
    }
  }

  function startAutoRefresh() {
    stopAutoRefresh();
    state.refreshTimer = window.setInterval(() => {
      if (state.open) void refresh(false);
    }, UPDATE_INTERVAL_MS);
  }

  function stopAutoRefresh() {
    if (state.refreshTimer != null) {
      clearInterval(state.refreshTimer);
      state.refreshTimer = null;
    }
  }

  function syncDockPosition() {
    const offset = detectExternalBottomOffset();
    dock.style.bottom = `${offset}px`;
  }

  function detectExternalBottomOffset() {
    let maxBottom = BASE_BOTTOM;

    const candidates = [...document.querySelectorAll('body *')].filter((el) => {
      if (!(el instanceof HTMLElement)) return false;
      if (el.closest('#acmt-root')) return false;

      const style = getComputedStyle(el);
      if (style.position !== 'fixed') return false;

      const left = Number.parseFloat(style.left);
      const bottom = Number.parseFloat(style.bottom);

      if (!Number.isFinite(left) || !Number.isFinite(bottom)) return false;
      if (left > 28) return false;

      const rect = el.getBoundingClientRect();
      if (rect.width <= 0 || rect.height <= 0) return false;

      const fromBottom = Math.max(0, window.innerHeight - rect.bottom);
      if (fromBottom > 240) return false;

      return true;
    });

    for (const el of candidates) {
      const rect = el.getBoundingClientRect();
      const bottom = Number.parseFloat(getComputedStyle(el).bottom || '0');
      if (!Number.isFinite(bottom)) continue;

      maxBottom = Math.max(maxBottom, Math.ceil(bottom + rect.height + GAP));
    }

    return maxBottom;
  }

  function formatTime(ts) {
    if (!ts) return '—';
    try {
      return new Intl.DateTimeFormat(undefined, {
        hour: '2-digit',
        minute: '2-digit',
      }).format(new Date(ts));
    } catch {
      return '—';
    }
  }

  function renderPanelFromSnapshot(snapshot, refreshing) {
    panelBody.innerHTML = '';

    if (!state.users.length) {
      const empty = document.createElement('div');
      empty.className = 'acmt-empty';
      empty.innerHTML = `
        No users are configured.<br>
        Open <strong>Settings</strong> and enter up to five AtCoder user names.
      `;
      panelBody.appendChild(empty);

      const footer = document.createElement('div');
      footer.className = 'acmt-footer';
      footer.textContent = snapshot ? `Updated ${formatTime(snapshot.updatedAt)}` : 'No cached data';
      panelBody.appendChild(footer);
      return;
    }

    if (!snapshot) {
      const list = document.createElement('div');
      list.className = 'acmt-skeleton-list';

      for (let i = 0; i < state.users.length; i++) {
        list.appendChild(createSkeletonCard());
      }

      panelBody.appendChild(list);

      const footer = document.createElement('div');
      footer.className = 'acmt-footer';
      footer.textContent = refreshing ? 'Loading…' : 'Waiting for data';
      panelBody.appendChild(footer);
      return;
    }

    const usersData = snapshot.users ?? {};
    for (const username of state.users) {
      const data = usersData[username] ?? null;
      panelBody.appendChild(createUserCard(username, data));
    }

    const footer = document.createElement('div');
    footer.className = 'acmt-footer';
    footer.textContent = snapshot ? `Updated ${formatTime(snapshot.updatedAt)}` : 'Waiting for data';
    panelBody.appendChild(footer);
  }

  function createSkeletonCard() {
    const card = document.createElement('div');
    card.className = 'acmt-skeleton-card';
    card.innerHTML = `
      <div class="acmt-skeleton-line acmt-skeleton-title"></div>
      <div class="acmt-skeleton-line acmt-skeleton-meta"></div>
      <div class="acmt-skeleton-chips">
        <div class="acmt-skeleton-chip"></div>
        <div class="acmt-skeleton-chip"></div>
        <div class="acmt-skeleton-chip"></div>
      </div>
    `;
    return card;
  }

  function createUserCard(username, data) {
    const card = document.createElement('div');
    card.className = 'acmt-card';

    const top = document.createElement('div');
    top.className = 'acmt-card-top';

    const left = document.createElement('div');
    left.style.minWidth = '0';

    const name = document.createElement('div');
    name.className = 'acmt-user-name';
    name.textContent = username;

    const meta = document.createElement('div');
    meta.className = 'acmt-meta';

    if (!data) {
      meta.textContent = 'Not found in standings yet.';
    } else {
      const score = data.score != null ? Math.round(data.score / 100) : null;
      const penalty = data.penalty ?? null;
      const solvedCount = data.solvedAssignments?.length ?? 0;
      const taskCount = data.taskCount ?? 0;

      meta.textContent =
        `Solved ${solvedCount}/${taskCount}` +
        (score != null ? ` · Score ${score}` : '') +
        (penalty != null ? ` · Penalty ${penalty}` : '');
    }

    left.appendChild(name);
    left.appendChild(meta);

    const badges = document.createElement('div');
    badges.className = 'acmt-badges';

    const rankBadge = document.createElement('span');
    rankBadge.className = 'acmt-pill';
    rankBadge.textContent = data?.rank != null ? `Rank #${data.rank}` : 'Rank —';
    badges.appendChild(rankBadge);

    top.appendChild(left);
    top.appendChild(badges);
    card.appendChild(top);

    const chipWrap = document.createElement('div');
    chipWrap.className = 'acmt-chip-wrap';

    const solvedAssignments = data?.solvedAssignments ?? [];
    if (solvedAssignments.length === 0) {
      const none = document.createElement('span');
      none.className = 'acmt-chip';
      none.textContent = 'No AC yet';
      none.style.color = '#666';
      chipWrap.appendChild(none);
    } else {
      for (const assignment of solvedAssignments) {
        const chip = document.createElement('span');
        chip.className = 'acmt-chip';
        chip.textContent = assignment;
        const task = data.taskByAssignment?.[assignment];
        if (task?.TaskName) chip.title = task.TaskName;
        chipWrap.appendChild(chip);
      }
    }

    card.appendChild(chipWrap);
    return card;
  }

  async function refresh(forceRender) {
    if (state.refreshing) return;

    if (!state.users.length) {
      renderLauncher();
      renderPanelFromSnapshot(state.snapshot, false);
      return;
    }

    const token = ++state.refreshToken;
    state.refreshing = true;

    if (state.open || forceRender) {
      setHeaderStatus(state.snapshot ? 'Updating…' : 'Loading…');
      renderPanelFromSnapshot(state.snapshot, true);
    }

    try {
      const res = await fetch(`/contests/${contestId}/standings/json`, {
        credentials: 'same-origin',
      });

      if (!res.ok) throw new Error(`standings/json failed: ${res.status}`);

      const json = await res.json();
      if (token !== state.refreshToken) return;

      const snapshot = buildSnapshot(json, state.users);
      snapshot.updatedAt = Date.now();

      state.snapshot = snapshot;
      saveSnapshot(contestId, snapshot);

      renderLauncher();

      if (state.open || forceRender) {
        renderPanelFromSnapshot(snapshot, false);
        setHeaderStatus(`Updated ${formatTime(snapshot.updatedAt)}`);
      }
    } catch (err) {
      console.warn('[AtCoder Multi Tracker]', err);

      if (state.open || forceRender) {
        if (state.snapshot) {
          renderPanelFromSnapshot(state.snapshot, false);
          setHeaderStatus('Update failed; showing cached data');
        } else {
          renderPanelFromSnapshot(null, false);
          setHeaderStatus('Failed to load data');
        }
      }
    } finally {
      if (token === state.refreshToken) {
        state.refreshing = false;
        renderLauncher();
      }
    }
  }

  function buildSnapshot(json, trackedUsers) {
    const taskInfo = Array.isArray(json.TaskInfo) ? json.TaskInfo : [];
    const rows = Array.isArray(json.StandingsData) ? json.StandingsData : [];

    const byUser = new Map();
    for (const row of rows) {
      if (row?.UserScreenName) byUser.set(row.UserScreenName, row);
    }

    const taskByAssignment = Object.fromEntries(
      taskInfo.map((t) => [t.Assignment, t])
    );

    const users = {};

    for (const username of trackedUsers) {
      const row = byUser.get(username);

      if (!row) {
        users[username] = {
          rank: null,
          score: null,
          penalty: null,
          solvedAssignments: [],
          taskCount: taskInfo.length,
          taskByAssignment,
        };
        continue;
      }

      const taskResults = row.TaskResults ?? {};
      const solvedAssignments = [];

      for (const task of taskInfo) {
        const result = getTaskResult(taskResults, task);
        if (isSolvedResult(result)) {
          solvedAssignments.push(task.Assignment);
        }
      }

      users[username] = {
        rank: row.Rank ?? null,
        score: row.TotalResult?.Score ?? null,
        penalty: row.TotalResult?.Penalty ?? null,
        solvedAssignments,
        taskCount: taskInfo.length,
        taskByAssignment,
      };
    }

    return {
      contestId,
      updatedAt: Date.now(),
      taskInfo,
      users,
    };
  }

  function getTaskResult(taskResults, task) {
    return (
      taskResults?.[task.TaskScreenName] ??
      taskResults?.[task.Assignment] ??
      taskResults?.[task.TaskName] ??
      null
    );
  }

  function isSolvedResult(result) {
    if (!result || typeof result !== 'object') return false;

    const score = Number(result.Score ?? result.Point ?? 0);
    const status = result.Status;

    return (
      score > 0 ||
      result.IsSolved === true ||
      result.IsAccepted === true ||
      status === 1 ||
      status === 'AC' ||
      status === 'Accepted'
    );
  }

  function openSettings() {
    modalTitle.textContent = 'Settings';
    modalInputs.innerHTML = '';

    for (let i = 0; i < MAX_USERS; i++) {
      const input = document.createElement('input');
      input.type = 'text';
      input.autocomplete = 'off';
      input.spellcheck = false;
      input.placeholder = 'Enter user name';
      input.value = state.users[i] || '';
      modalInputs.appendChild(input);
    }

    modal.classList.add('open');
  }

  function closeSettings() {
    modal.classList.remove('open');
  }

  function saveSettingsFromModal() {
    state.users = [...modalInputs.querySelectorAll('input')]
      .map((i) => i.value.trim())
      .filter(Boolean)
      .slice(0, MAX_USERS);

    saveUsers();
    closeSettings();
    renderLauncher();
    renderPanelFromSnapshot(state.snapshot, false);

    if (state.open) {
      setHeaderStatus(state.snapshot ? 'Updating…' : 'Loading…');
      void refresh(true);
    } else {
      setHeaderStatus(state.snapshot ? 'Cached' : 'No cache');
    }
  }
})();