AtCoder Multi Tracker

Track up to 5 users with stable UI and no overlap

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

You will need to install an extension such as Tampermonkey to install this script.

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Advertisement:

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

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');
    }
  }
})();