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