← back to kodanatlas__trails-jp

Function bodies 121 total

All specs Real LLM only Function bodies
classifyType function · typescript · L67-L84 (18 LOC)
scripts/build-analysis-index.ts
function classifyType(
  appearances: { type: string; totalPoints: number }[]
): AthleteSummary["type"] {
  const forestApps = appearances.filter((r) => r.type.includes("forest"));
  const sprintApps = appearances.filter((r) => r.type.includes("sprint"));

  if (forestApps.length === 0 && sprintApps.length === 0) return "unknown";
  if (forestApps.length === 0) return "sprinter";
  if (sprintApps.length === 0) return "forester";

  const bestForestPts = Math.max(...forestApps.map((r) => r.totalPoints));
  const bestSprintPts = Math.max(...sprintApps.map((r) => r.totalPoints));

  const ratio = bestForestPts / bestSprintPts;
  if (ratio > 1.15) return "forester";
  if (ratio < 1 / 1.15) return "sprinter";
  return "allrounder";
}
normalizeClubName function · typescript · L107-L125 (19 LOC)
scripts/build-analysis-index.ts
function normalizeClubName(raw: string): string {
  let name = raw.trim();

  // 末尾のスペース+数字を除去 (e.g. "金沢大学 3" → "金沢大学")
  name = name.replace(/\s+\d+$/, "");

  // "OLクラブ" → "OLC"
  name = name.replace(/OLクラブ$/, "OLC");

  // "ES関東クラブ" / "ES関東" → "ES関東C"
  if (name === "ES関東" || name === "ES関東クラブ") {
    name = "ES関東C";
  }

  // 大文字小文字統一: olc → OLC
  name = name.replace(/olc$/i, "OLC");

  return name;
}
dedupeEvents function · typescript · L198-L209 (12 LOC)
scripts/build-analysis-index.ts
function dedupeEvents(events: ParsedEvent[]): ParsedEvent[] {
  const seen = new Set<string>();
  const result: ParsedEvent[] = [];
  for (const e of events) {
    const key = `${e.date}:${e.points}`;
    if (!seen.has(key)) {
      seen.add(key);
      result.push(e);
    }
  }
  return result.sort((a, b) => a.date.localeCompare(b.date));
}
calcConsistency function · typescript · L211-L219 (9 LOC)
scripts/build-analysis-index.ts
function calcConsistency(events: ParsedEvent[]): number {
  if (events.length < 2) return 0;
  const pts = events.map((e) => e.points);
  const mean = pts.reduce((a, b) => a + b, 0) / pts.length;
  if (mean === 0) return 0;
  const variance = pts.reduce((s, p) => s + (p - mean) ** 2, 0) / pts.length;
  const cv = Math.sqrt(variance) / mean;
  return Math.round(Math.max(0, Math.min(100, (1 - cv / 0.3) * 100)));
}
calcRecentForm function · typescript · L221-L229 (9 LOC)
scripts/build-analysis-index.ts
function calcRecentForm(events: ParsedEvent[]): number {
  if (events.length < 2) return 0;
  const sorted = [...events].sort((a, b) => b.date.localeCompare(a.date));
  const recent = sorted.slice(0, 3);
  const recentAvg = recent.reduce((s, e) => s + e.points, 0) / recent.length;
  const allAvg = sorted.reduce((s, e) => s + e.points, 0) / sorted.length;
  if (allAvg === 0) return 0;
  return Math.round(((recentAvg - allAvg) / allAvg) * 100);
}
fetchLapCenterEvents function · javascript · L34-L88 (55 LOC)
scripts/match-lapcenter.mjs
async function fetchLapCenterEvents(year) {
  const url = `${BASE_URL}/index.jsp?year=${year}`;
  console.log(`Lap Center ${year} 取得中: ${url}`);

  const res = await fetch(url, {
    headers: { "User-Agent": "trails.jp/1.0 (lapcenter match)" },
  });
  if (!res.ok) {
    console.log(`  エラー: HTTP ${res.status}`);
    return [];
  }

  const html = await res.text();
  const $ = cheerio.load(html);
  const events = [];

  let currentMonth = 0;

  $("table.table-condensed tr").each((_, tr) => {
    const tds = $(tr).find("td");
    if (tds.length < 3) return;

    // 月の取得
    const monthText = tds.eq(0).text().trim();
    const monthMatch = monthText.match(/(\d{1,2})月/);
    if (monthMatch) {
      currentMonth = parseInt(monthMatch[1], 10);
    }
    if (!currentMonth) return;

    // 日の取得
    const dayText = tds.eq(1).text().trim();
    const dayMatch = dayText.match(/(\d{1,2})日/);
    if (!dayMatch) return;
    const day = parseInt(dayMatch[1], 10);

    const date = `${year}-${String(c
normalize function · javascript · L108-L305 (198 LOC)
scripts/match-lapcenter.mjs
function normalize(name) {
  let s = name;
  // 全角→半角の基本変換
  s = s.replace(/[A-Za-z0-9]/g, (c) =>
    String.fromCharCode(c.charCodeAt(0) - 0xfee0)
  );
  // 【中止】等のタグを除去
  s = s.replace(/【[^】]*】/g, "");
  // 第XX回 を除去
  s = s.replace(/第\s*[0-9一二三四五六七八九十百千]+\s*回/g, "");
  // 年度・年を除去
  s = s.replace(/(令和|平成|昭和)\s*[0-9一二三四五六七八九十]+\s*年度?/g, "");
  s = s.replace(/20\d{2}年度?/g, "");
  // 日付パターンを除去 (20250112 等)
  s = s.replace(/20\d{6}/g, "");
  // 括弧内を除去
  s = s.replace(/[((][^))]*[))]/g, "");
  // 特殊文字・空白を除去してトークン化
  s = s.replace(/[・\-\s &&「」『』【】〜~//\\.,、。!!??::;;##@@++==__<><>'"'"'"^`~||{}\[\][]]/g, " ");
  // 連続空白をトリム
  return s.trim();
}

/**
 * トークンがストップワード(またはその部分文字列)かどうかをチェック
 */
function isStopRelated(token) {
  if (STOP_WORDS.has(token)) return true;
  // トークンがいずれかのストップワードの部分文字列ならストップ扱い
  for (const sw of STOP_WORDS) {
    if (sw.includes(token) && token.length < sw.length) return true;
  }
  return false;
}

/**
 * 正規化した名前から意味のあるキーワード (3文字以上、ストップワード除外) を抽出
 */
function extractSigni
Powered by Repobility — scan your code at https://repobility.com
isStopRelated function · javascript · L134-L141 (8 LOC)
scripts/match-lapcenter.mjs
function isStopRelated(token) {
  if (STOP_WORDS.has(token)) return true;
  // トークンがいずれかのストップワードの部分文字列ならストップ扱い
  for (const sw of STOP_WORDS) {
    if (sw.includes(token) && token.length < sw.length) return true;
  }
  return false;
}
extractSignificantTokens function · javascript · L146-L149 (4 LOC)
scripts/match-lapcenter.mjs
function extractSignificantTokens(normalizedName) {
  const tokens = normalizedName.split(/\s+/).filter((t) => t.length >= 3);
  return tokens.filter((t) => !isStopRelated(t));
}
coreString function · javascript · L154-L161 (8 LOC)
scripts/match-lapcenter.mjs
function coreString(normalizedName) {
  let s = normalizedName.replace(/\s+/g, "");
  // ストップワードを除去
  for (const sw of STOP_WORDS) {
    s = s.replaceAll(sw, "");
  }
  return s;
}
fuzzyMatch function · javascript · L167-L236 (70 LOC)
scripts/match-lapcenter.mjs
function fuzzyMatch(name1, name2) {
  const norm1 = normalize(name1);
  const norm2 = normalize(name2);
  const full1 = norm1.replace(/\s+/g, "");
  const full2 = norm2.replace(/\s+/g, "");

  // 正規化後の文字列が空なら不一致
  if (!full1 || !full2) return false;

  // 完全一致 (正規化後)
  if (full1 === full2) return true;

  // 一方が他方を含む (長さが短い方が4文字以上の場合のみ)
  const shorter = full1.length <= full2.length ? full1 : full2;
  const longer = full1.length <= full2.length ? full2 : full1;
  if (shorter.length >= 4 && longer.includes(shorter)) return true;

  // ストップワード除去後のコア文字列で比較
  const core1 = coreString(norm1);
  const core2 = coreString(norm2);

  if (core1.length >= 3 && core2.length >= 3) {
    // コア完全一致
    if (core1 === core2) return true;
    // コア包含 (短い方が4文字以上)
    const cShorter = core1.length <= core2.length ? core1 : core2;
    const cLonger = core1.length <= core2.length ? core2 : core1;
    if (cShorter.length >= 4 && cLonger.includes(cShorter)) return true;
  }

  // トークンベース: ストップワードを除いた有意なトークン
 
main function · javascript · L242-L329 (88 LOC)
scripts/match-lapcenter.mjs
async function main() {
  console.log("=== Lap Center マッチング開始 ===\n");

  // 1. Lap Center イベント取得
  const lcEvents = [];
  for (const year of [2025, 2026]) {
    const events = await fetchLapCenterEvents(year);
    lcEvents.push(...events);
    await new Promise((r) => setTimeout(r, DELAY_MS));
  }

  console.log(`\nLap Center 合計: ${lcEvents.length} 件\n`);

  // 2. 既存 events.json 読み込み
  const srcPath = join(__dirname, "..", "src", "data", "events.json");
  const joeEvents = JSON.parse(readFileSync(srcPath, "utf-8"));
  console.log(`JOE イベント: ${joeEvents.length} 件\n`);

  // 既存の lapcenter フィールドをクリア (再実行時のため)
  for (const e of joeEvents) {
    delete e.lapcenter_event_id;
    delete e.lapcenter_url;
  }

  // 3. 日付でグループ化 (Lap Center 側)
  const lcByDate = new Map();
  for (const lc of lcEvents) {
    if (!lcByDate.has(lc.date)) lcByDate.set(lc.date, []);
    lcByDate.get(lc.date).push(lc);
  }

  // 4. マッチング (日付完全一致のみ - end_date 範囲は使わない)
  let matchCount = 0;
  const matchedPairs = [];
  
scrapeTopPage function · javascript · L16-L75 (60 LOC)
scripts/scrape-events.mjs
async function scrapeTopPage() {
  console.log("トップページ取得中...");
  const res = await fetch(BASE_URL, {
    headers: { "User-Agent": "trails.jp/1.0 (event sync)" },
  });
  const html = await res.text();
  const $ = cheerio.load(html);
  const events = [];

  $("table.index tr").each((_, tr) => {
    const $tr = $(tr);
    const tds = $tr.find("td");
    if (tds.length < 2) return;

    // td1 にイベントリンクがある
    const link = tds.eq(1).find("a[href*='/event/view/']").first();
    if (!link.length) return;

    const href = link.attr("href") || "";
    const idMatch = href.match(/\/event\/view\/(\d+)/);
    if (!idMatch) return;

    const joe_event_id = parseInt(idMatch[1], 10);
    const name = link.text().trim();

    // 日付パース
    const dateText = tds.eq(0).text().trim();
    const { date, end_date } = parseDate(dateText);

    // 種別(最初の span)
    const typeSpan = tds.eq(1).find("span").first().text().trim();
    const event_type = typeSpan || "";

    // 場所: リンクの後のテキストノード(都道府県や会場名)
    co
scrapeArchive function · javascript · L80-L130 (51 LOC)
scripts/scrape-events.mjs
async function scrapeArchive(year) {
  const url = `${BASE_URL}/event/archive/${year}`;
  console.log(`アーカイブ ${year} 取得中...`);
  const res = await fetch(url, {
    headers: { "User-Agent": "trails.jp/1.0 (event sync)" },
  });
  if (!res.ok) {
    console.log(`  エラー: HTTP ${res.status}`);
    return [];
  }
  const html = await res.text();
  const $ = cheerio.load(html);
  const events = [];

  $("table tr").each((_, tr) => {
    const $tr = $(tr);
    const tds = $tr.find("td");
    if (tds.length < 2) return;

    const link = $tr.find("a[href*='/event/view/']").first();
    if (!link.length) return;

    const href = link.attr("href") || "";
    const idMatch = href.match(/\/event\/view\/(\d+)/);
    if (!idMatch) return;

    const joe_event_id = parseInt(idMatch[1], 10);
    const name = link.text().trim();

    const dateText = tds.eq(0).text().trim();
    const { date, end_date } = parseDate(dateText, year);

    const td1Text = tds.eq(1).text().trim();
    const prefecture = ex
parseDate function · javascript · L133-L165 (33 LOC)
scripts/scrape-events.mjs
function parseDate(text, defaultYear) {
  // "2026/ 1/7 - 3/20" or "2/28 (土)" or "2/28 - 26" or "2/25 - 26"
  const now = new Date();
  const year = defaultYear || now.getFullYear();

  // Full year format: "2026/ 1/7" or "2026/ 1/7 - 3/20"
  const fullMatch = text.match(/(\d{4})\s*\/\s*(\d{1,2})\s*\/\s*(\d{1,2})(?:\s*-\s*(?:(\d{1,2})\s*\/\s*)?(\d{1,2}))?/);
  if (fullMatch) {
    const [, y, m, d, endM, endD] = fullMatch;
    const date = `${y}-${m.padStart(2, "0")}-${d.padStart(2, "0")}`;
    let end_date;
    if (endD) {
      const em = endM || m;
      end_date = `${y}-${em.padStart(2, "0")}-${endD.padStart(2, "0")}`;
    }
    return { date, end_date };
  }

  // Short format: "2/28 (土)" or "2/28 - 3/1" or "2/25 - 26"
  const shortMatch = text.match(/(\d{1,2})\s*\/\s*(\d{1,2})(?:\s*-\s*(?:(\d{1,2})\s*\/\s*)?(\d{1,2}))?/);
  if (shortMatch) {
    const [, m, d, endM, endD] = shortMatch;
    const date = `${year}-${m.padStart(2, "0")}-${d.padStart(2, "0")}`;
    let end_date;
    i
If a scraper extracted this row, it came from Repobility (https://repobility.com)
extractLocation function · javascript · L168-L188 (21 LOC)
scripts/scrape-events.mjs
function extractLocation(fullText, eventName) {
  // イベント名以降のテキストから都道府県を探す
  const idx = fullText.indexOf(eventName);
  const after = idx >= 0 ? fullText.substring(idx + eventName.length).trim() : fullText;

  // 都道府県パターン
  const prefMatch = after.match(/(北海道|青森県|岩手県|宮城県|秋田県|山形県|福島県|茨城県|栃木県|群馬県|埼玉県|千葉県|東京都|神奈川県|新潟県|富山県|石川県|福井県|山梨県|長野県|岐阜県|静岡県|愛知県|三重県|滋賀県|京都府|大阪府|兵庫県|奈良県|和歌山県|鳥取県|島根県|岡山県|広島県|山口県|徳島県|香川県|愛媛県|高知県|福岡県|佐賀県|長崎県|熊本県|大分県|宮崎県|鹿児島県|沖縄県)/);
  if (prefMatch) return prefMatch[1];

  // 「○○市」「○○町」「○○村」パターン
  const cityMatch = after.match(/([^\s「」]+?[市町村区])/);
  if (cityMatch) return cityMatch[1];

  // カッコ内の地名
  const bracketMatch = after.match(/「([^」]+)」/);
  if (bracketMatch) return bracketMatch[1];

  // 最初の非空白文字列
  const firstWord = after.replace(/[((][^))]*[))]/g, "").trim().split(/\s+/)[0];
  return firstWord || "";
}
parseEntryStatus function · javascript · L191-L196 (6 LOC)
scripts/scrape-events.mjs
function parseEntryStatus(text) {
  if (!text || text === "-") return "none";
  if (text.includes("受付中") || text.includes("あと")) return "open";
  if (text.includes("締切")) return "closed";
  return "none";
}
scrapeUpdateHistory function · javascript · L201-L251 (51 LOC)
scripts/scrape-events.mjs
async function scrapeUpdateHistory() {
  console.log("更新履歴取得中...");
  const res = await fetch(BASE_URL, {
    headers: { "User-Agent": "trails.jp/1.0 (event sync)" },
  });
  const html = await res.text();
  const $ = cheerio.load(html);

  // 更新履歴: Map<event_id, update_label>
  const updates = new Map();

  // 更新履歴セクション内のリンクからイベントIDを抽出
  // 構造: <h2>更新履歴</h2> の後のコンテンツ
  let inHistory = false;
  $("h2, h3, div, p, li, a, table").each((_, el) => {
    const $el = $(el);
    const tagName = el.tagName?.toLowerCase();

    // 更新履歴の見出しを見つける
    if ((tagName === "h2" || tagName === "h3") && $el.text().includes("更新履歴")) {
      inHistory = true;
      return;
    }
    // 次の見出しで終了
    if (inHistory && (tagName === "h2" || tagName === "h3") && !$el.text().includes("更新履歴")) {
      inHistory = false;
      return;
    }

    if (!inHistory) return;

    // リンクからイベントIDを抽出
    if (tagName === "a") {
      const href = $el.attr("href") || "";
      const idMatch = href.match(/\/event\/view\/(\d+)/
main function · javascript · L253-L311 (59 LOC)
scripts/scrape-events.mjs
async function main() {
  console.log("japan-o-entry.com イベント全件取得開始\n");

  // トップページ(現在〜未来のイベント、エントリー状態付き)
  const topEvents = await scrapeTopPage();
  await new Promise(r => setTimeout(r, 1500));

  // 更新履歴
  const updateHistory = await scrapeUpdateHistory();

  // アーカイブ(過去〜現在)- 直近2年分
  const currentYear = new Date().getFullYear();
  const archiveEvents = [];
  for (const year of [currentYear - 1, currentYear, currentYear + 1]) {
    const events = await scrapeArchive(year);
    archiveEvents.push(...events);
    await new Promise(r => setTimeout(r, 1500));
  }

  // マージ(トップページのデータを優先、IDで重複排除)
  const byId = new Map();
  // まずアーカイブを入れる
  for (const e of archiveEvents) {
    if (e.joe_event_id && e.date) byId.set(e.joe_event_id, e);
  }
  // トップページで上書き(エントリー状態やタグがより正確)
  for (const e of topEvents) {
    if (e.joe_event_id && e.date) byId.set(e.joe_event_id, e);
  }

  // 更新履歴フラグを付与
  for (const [eventId, label] of updateHistory) {
    const event = byId.get(eventId);
    if (event) {
parseRankingPage function · javascript · L46-L86 (41 LOC)
scripts/scrape-rankings.mjs
function parseRankingPage(html) {
  const $ = cheerio.load(html);
  const entries = [];

  const eventHeaders = [];
  $("table thead tr th, table tr:first-child th").each((i, th) => {
    const text = $(th).text().trim();
    if (i > 3 && text) eventHeaders.push(text);
  });

  $("table tbody tr").each((_, row) => {
    const $row = $(row);
    const cells = $row.find("td");
    if (cells.length < 4) return;

    const rank = parseInt(cells.eq(0).text().trim(), 10);
    if (isNaN(rank)) return;

    const athlete_name = cells.eq(1).text().trim();
    if (!athlete_name) return;

    const club = cells.eq(2).text().trim();
    const total_points = parseFloat(cells.eq(3).text().trim()) || 0;
    const rowClass = $row.attr("class") || "";
    const is_active = !rowClass.includes("out_ranker");

    const event_scores = [];
    cells.each((i, cell) => {
      if (i > 3 && eventHeaders[i - 4]) {
        const pts = parseFloat($(cell).text().trim());
        if (!isNaN(pts) && pts > 0) {
    
fetchPage function · javascript · L88-L92 (5 LOC)
scripts/scrape-rankings.mjs
async function fetchPage(url) {
  const res = await fetch(url, { headers: { "User-Agent": "trails.jp/1.0 (ranking sync)" } });
  if (!res.ok) return null;
  return await res.text();
}
fetchAllPages function · javascript · L94-L122 (29 LOC)
scripts/scrape-rankings.mjs
async function fetchAllPages(typeId, classId, label) {
  const baseUrl = `${BASE_URL}/${typeId}/${classId}`;
  const allEntries = [];
  const seenKeys = new Set();

  for (let page = 0; ; page++) {
    const pageUrl = page === 0 ? baseUrl : `${baseUrl}/${page}`;
    if (page > 0) await new Promise(r => setTimeout(r, 1200));

    const html = await fetchPage(pageUrl);
    if (!html) break;

    const entries = parseRankingPage(html);
    if (entries.length === 0) break;

    let added = 0;
    for (const entry of entries) {
      const key = `${entry.rank}:${entry.athlete_name}`;
      if (!seenKeys.has(key)) {
        seenKeys.add(key);
        allEntries.push(entry);
        added++;
      }
    }
    process.stdout.write(page === 0 ? `${added}` : `+${added}`);
  }

  return allEntries;
}
main function · javascript · L124-L178 (55 LOC)
scripts/scrape-rankings.mjs
async function main() {
  console.log("japan-o-entry.com ランキング全カテゴリ取得開始\n");

  const allData = {};
  let totalAthletes = 0;
  let totalCategories = 0;

  for (const config of RANKING_CONFIGS) {
    console.log(`\n[${config.label}] (typeId=${config.typeId}, ${config.classes.length}クラス)`);

    for (const cls of config.classes) {
      const key = `${config.type}:${cls.name}`;
      process.stdout.write(`  ${cls.label}: `);

      const entries = await fetchAllPages(config.typeId, cls.id, cls.label);

      if (entries.length > 0) {
        allData[key] = entries;
        totalAthletes += entries.length;
        totalCategories++;
        console.log(` → ${entries.length}人`);
      } else {
        console.log(` → 0人 (skip)`);
      }

      await new Promise(r => setTimeout(r, 1200));
    }
  }

  // カテゴリ別 JSON を public/ に保存
  const pubDir = join(__dirname, "..", "public", "data", "rankings");
  mkdirSync(pubDir, { recursive: true });

  for (const [key, entries] of Object.entries(allD
Repobility analyzer · published findings · https://repobility.com
AboutPage function · typescript · L10-L82 (73 LOC)
src/app/about/page.tsx
export default function AboutPage() {
  return (
    <div className="mx-auto max-w-3xl px-4 py-10">
      <Link href="/" className="mb-6 inline-flex items-center gap-1 text-xs text-muted hover:text-foreground">
        <ArrowLeft className="h-3.5 w-3.5" />
        トップに戻る
      </Link>

      <h1 className="text-2xl font-bold">このサイトについて</h1>

      <div className="mt-6 space-y-6 text-sm leading-relaxed text-muted">
        <p>
          <strong className="text-foreground">trails.jp</strong> は、日本のオリエンテーリング情報を一つに集約するプラットフォームです。
          大会情報、テレイン(O-map)データベース、GPS追跡、ランキングなど、オリエンテーリングに関わる情報へのアクセスを提供します。
        </p>

        <div className="space-y-4">
          <h2 className="text-lg font-semibold text-foreground">主な機能</h2>

          <div className="grid gap-3 sm:grid-cols-2">
            {[
              {
                icon: CalendarDays,
                title: "イベント",
                desc: "JOY(日本オリエンテーリング協会)から大会情報を日次で自動取得。エントリー状況や開催地を一覧・カレンダーで確認できます。",
              },
            
AnalysisHub function · typescript · L19-L185 (167 LOC)
src/app/analysis/AnalysisHub.tsx
export function AnalysisHub() {
  const [activeTab, setActiveTab] = useState<Tab>("athlete");
  const [athleteIndex, setAthleteIndex] = useState<AthleteIndex | null>(null);
  const [clubIndex, setClubIndex] = useState<ClubIndex | null>(null);
  const [loading, setLoading] = useState(true);

  // 選手検索
  const [searchQuery, setSearchQuery] = useState("");
  const [selectedAthlete, setSelectedAthlete] = useState<AthleteSummary | null>(null);

  // 比較用
  const [compareA, setCompareA] = useState<AthleteSummary | null>(null);
  const [compareB, setCompareB] = useState<AthleteSummary | null>(null);

  useEffect(() => {
    Promise.all([
      fetch("/data/athlete-index.json").then((r) => r.json()),
      fetch("/data/club-stats.json").then((r) => r.json()),
    ]).then(([ai, ci]) => {
      setAthleteIndex(ai);
      setClubIndex(ci);
      setLoading(false);
    }).catch(() => setLoading(false));
  }, []);

  const searchResults = useMemo(() => {
    if (!athleteIndex || !searchQuery || sear
AthleteDetail function · typescript · L22-L54 (33 LOC)
src/app/analysis/AthleteDetail.tsx
export function AthleteDetail({ summary }: Props) {
  const [profile, setProfile] = useState<AthleteProfile | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    setLoading(true);
    loadAthleteDetail(summary).then((p) => {
      setProfile(p);
      setLoading(false);
    });
  }, [summary]);

  if (loading) {
    return (
      <div className="flex items-center justify-center py-12">
        <Loader2 className="h-4 w-4 animate-spin text-primary" />
        <span className="ml-2 text-sm text-muted">詳細データを読み込み中...</span>
      </div>
    );
  }

  if (!profile) return null;

  return (
    <div className="space-y-5">
      <ProfileHeader profile={profile} />
      <TypeBadge profile={profile} />
      <StatsCards profile={profile} />
      <ScoreChart profile={profile} />
      <RecentEvents profile={profile} />
    </div>
  );
}
ProfileHeader function · typescript · L57-L74 (18 LOC)
src/app/analysis/AthleteDetail.tsx
function ProfileHeader({ profile }: { profile: AthleteProfile }) {
  return (
    <div className="rounded-lg border border-border bg-card p-4">
      <div className="flex items-center justify-between">
        <div>
          <h2 className="text-lg font-bold">{profile.name}</h2>
          <p className="text-xs text-muted">{profile.clubs.join(" / ")}</p>
        </div>
        <div className="text-right">
          <p className="text-2xl font-bold text-primary">
            {profile.bestPoints.toLocaleString(undefined, { maximumFractionDigits: 1 })}
          </p>
          <p className="text-[10px] text-muted">最高ポイント</p>
        </div>
      </div>
    </div>
  );
}
TypeBadge function · typescript · L77-L132 (56 LOC)
src/app/analysis/AthleteDetail.tsx
function TypeBadge({ profile }: { profile: AthleteProfile }) {
  const { forestRank, forestPoints, sprintRank, sprintPoints } = getBestRanks(
    profile.appearances
  );

  const typeColors: Record<string, string> = {
    sprinter: "bg-blue-500/15 text-blue-400",
    forester: "bg-green-500/15 text-green-400",
    allrounder: "bg-purple-500/15 text-purple-400",
    unknown: "bg-white/10 text-muted",
  };

  const total = forestPoints + sprintPoints;

  return (
    <div className="rounded-lg border border-border bg-card p-4">
      <div className="mb-3 flex items-center gap-2">
        <Zap className="h-4 w-4 text-primary" />
        <span className="text-xs font-semibold uppercase tracking-wider text-muted">特性分類</span>
      </div>

      <div className="flex items-center gap-3">
        <span className={`rounded-full px-3 py-1 text-xs font-bold ${typeColors[profile.type]}`}>
          {typeLabel(profile.type)}
        </span>
        <span className="text-xs text-muted">
          F
StatsCards function · typescript · L135-L193 (59 LOC)
src/app/analysis/AthleteDetail.tsx
function StatsCards({ profile }: { profile: AthleteProfile }) {
  const allEvents = useMemo(() => getAllEvents(profile), [profile]);
  const consistency = calcConsistency(allEvents);
  const recentForm = calcRecentForm(allEvents);
  const best = allEvents.length > 0
    ? allEvents.reduce((max, e) => (e.points > max.points ? e : max))
    : null;

  return (
    <div className="grid gap-3 sm:grid-cols-3">
      {/* 安定性 */}
      <div className="rounded-lg border border-border bg-card p-3">
        <div className="flex items-center gap-1.5">
          <Target className="h-3.5 w-3.5 text-primary" />
          <span className="text-[10px] font-semibold uppercase tracking-wider text-muted">安定性</span>
        </div>
        <p className="mt-1 text-2xl font-bold">
          {allEvents.length >= 2 ? `${consistency}` : "—"}
          {allEvents.length >= 2 && <span className="text-sm text-muted">/100</span>}
        </p>
        <p className="text-[10px] text-muted">
          {consistency >= 
ScoreChart function · typescript · L196-L337 (142 LOC)
src/app/analysis/AthleteDetail.tsx
function ScoreChart({ profile }: { profile: AthleteProfile }) {
  const { forestEvents, sprintEvents, chartData, hasForest, hasSprint } = useMemo(() => {
    const fEvents: { date: string; eventName: string; points: number }[] = [];
    const sEvents: { date: string; eventName: string; points: number }[] = [];
    const seenF = new Set<string>();
    const seenS = new Set<string>();

    for (const r of profile.rankings) {
      const isForest = r.type.includes("forest");
      for (const e of r.events) {
        if (!e.date) continue;
        const key = `${e.date}:${e.eventName}`;
        if (isForest) {
          if (!seenF.has(key)) { seenF.add(key); fEvents.push(e); }
        } else {
          if (!seenS.has(key)) { seenS.add(key); sEvents.push(e); }
        }
      }
    }

    // 日付でまとめる
    const dateMap = new Map<string, { date: string; forest?: number; sprint?: number; fName?: string; sName?: string }>();
    for (const e of fEvents) {
      if (!dateMap.has(e.date)) dateMap
RecentEvents function · typescript · L340-L500 (161 LOC)
src/app/analysis/AthleteDetail.tsx
function RecentEvents({ profile }: { profile: AthleteProfile }) {
  const allEvents = useMemo(() => getAllEvents(profile), [profile]);

  if (allEvents.length === 0) return null;

  // 新しい順にソート
  const recent = [...allEvents].sort((a, b) => b.date.localeCompare(a.date));
  const maxPoints = Math.max(...allEvents.map((e) => e.points));
  const avgPoints = allEvents.reduce((s, e) => s + e.points, 0) / allEvents.length;

  // 成績レベル判定: 平均・標準偏差ベースで5段階
  const variance = allEvents.reduce((s, e) => s + (e.points - avgPoints) ** 2, 0) / allEvents.length;
  const stdDev = Math.sqrt(variance);

  function performanceLevel(points: number): "excellent" | "good" | "average" | "below" | "poor" {
    if (points >= avgPoints + stdDev) return "excellent";
    if (points >= avgPoints + stdDev * 0.3) return "good";
    if (points >= avgPoints - stdDev * 0.3) return "average";
    if (points >= avgPoints - stdDev) return "below";
    return "poor";
  }

  const levelColors = {
    excellent: { bar: "bg-gr
Repobility's GitHub App fixes findings like these · https://github.com/apps/repobility-bot
performanceLevel function · typescript · L354-L360 (7 LOC)
src/app/analysis/AthleteDetail.tsx
  function performanceLevel(points: number): "excellent" | "good" | "average" | "below" | "poor" {
    if (points >= avgPoints + stdDev) return "excellent";
    if (points >= avgPoints + stdDev * 0.3) return "good";
    if (points >= avgPoints - stdDev * 0.3) return "average";
    if (points >= avgPoints - stdDev) return "below";
    return "poor";
  }
ClubAnalysis function · typescript · L25-L116 (92 LOC)
src/app/analysis/ClubAnalysis.tsx
export function ClubAnalysis({ clubIndex }: Props) {
  const [searchQuery, setSearchQuery] = useState("");
  const [sortBy, setSortBy] = useState<SortKey>("active");
  const [expandedClub, setExpandedClub] = useState<string | null>(null);

  const clubs = useMemo(() => {
    let list = Object.values(clubIndex.clubs);

    if (searchQuery) {
      const q = searchQuery.toLowerCase();
      list = list.filter((c) =>
        c.name.toLowerCase().includes(q) ||
        c.members.some((m) => m.name.toLowerCase().includes(q))
      );
    }

    list.sort((a, b) => {
      switch (sortBy) {
        case "members": return b.memberCount - a.memberCount;
        case "avgPoints": return b.avgPoints - a.avgPoints;
        case "active": return b.activeCount - a.activeCount;
      }
    });

    return list;
  }, [clubIndex, searchQuery, sortBy]);

  return (
    <div>
      {/* Search + Sort */}
      <div className="mb-4 flex items-center gap-3">
        <div className="relative flex-1">
      
MemberRow function · typescript · L244-L303 (60 LOC)
src/app/analysis/ClubAnalysis.tsx
function MemberRow({ member: m }: { member: ClubMember }) {
  return (
    <div className="flex items-center gap-1.5 rounded bg-white/[0.03] p-2">
      {/* Rank */}
      <span className="w-7 flex-shrink-0 text-center text-xs font-bold text-primary">
        {m.bestRank}
      </span>

      {/* Name + class */}
      <div className="min-w-0 flex-1">
        <span className="truncate text-sm">{m.name}</span>
        <span className="ml-1.5 text-[9px] text-muted">{m.className}</span>
      </div>

      {/* Type badge */}
      <span className={`flex-shrink-0 rounded-full px-1.5 py-0.5 text-[9px] font-bold ${typeBadgeColors[m.athleteType]}`}>
        {typeLabel(m.athleteType)}
      </span>

      {/* Recent form arrow */}
      <span className="flex w-10 flex-shrink-0 items-center justify-end gap-0.5">
        {m.recentForm > 3 ? (
          <TrendingUp className="h-3 w-3 text-green-400" />
        ) : m.recentForm < -3 ? (
          <TrendingDown className="h-3 w-3 text-red-400" />
 
CompareAthletes function · typescript · L26-L57 (32 LOC)
src/app/analysis/CompareAthletes.tsx
export function CompareAthletes({ athleteIndex, compareA, compareB, onSelectA, onSelectB }: Props) {
  return (
    <div className="space-y-4">
      <div className="grid gap-3 sm:grid-cols-2">
        <AthleteSearchSlot
          label="選手A"
          color="#00e5ff"
          athleteIndex={athleteIndex}
          selected={compareA}
          onSelect={onSelectA}
        />
        <AthleteSearchSlot
          label="選手B"
          color="#f97316"
          athleteIndex={athleteIndex}
          selected={compareB}
          onSelect={onSelectB}
        />
      </div>

      {compareA && compareB && (
        <CompareView a={compareA} b={compareB} />
      )}

      {(!compareA || !compareB) && (
        <div className="rounded-lg border border-border bg-card py-12 text-center text-sm text-muted">
          2名の選手を選択して比較を開始
        </div>
      )}
    </div>
  );
}
CompareView function · typescript · L143-L199 (57 LOC)
src/app/analysis/CompareAthletes.tsx
function CompareView({ a, b }: { a: AthleteSummary; b: AthleteSummary }) {
  const [profileA, setProfileA] = useState<AthleteProfile | null>(null);
  const [profileB, setProfileB] = useState<AthleteProfile | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    setLoading(true);
    Promise.all([loadAthleteDetail(a), loadAthleteDetail(b)]).then(([pa, pb]) => {
      setProfileA(pa);
      setProfileB(pb);
      setLoading(false);
    });
  }, [a, b]);

  if (loading) {
    return (
      <div className="flex items-center justify-center py-12">
        <Loader2 className="h-4 w-4 animate-spin text-primary" />
        <span className="ml-2 text-sm text-muted">比較データを読み込み中...</span>
      </div>
    );
  }

  if (!profileA || !profileB) return null;

  const eventsA = getAllEvents(profileA);
  const eventsB = getAllEvents(profileB);
  const consistencyA = calcConsistency(eventsA);
  const consistencyB = calcConsistency(eventsB);
  const formA = calcRecentFor
CompareRow function · typescript · L201-L223 (23 LOC)
src/app/analysis/CompareAthletes.tsx
function CompareRow({
  label,
  valueA,
  valueB,
  highlightA,
}: {
  label: string;
  valueA: string;
  valueB: string;
  highlightA?: boolean;
}) {
  return (
    <div className="flex items-center gap-2 text-sm">
      <span className={`w-24 text-right font-mono font-bold ${highlightA === true ? "text-[#00e5ff]" : highlightA === false ? "text-muted" : ""}`}>
        {valueA}
      </span>
      <span className="flex-1 text-center text-[10px] text-muted">{label}</span>
      <span className={`w-24 font-mono font-bold ${highlightA === false ? "text-[#f97316]" : highlightA === true ? "text-muted" : ""}`}>
        {valueB}
      </span>
    </div>
  );
}
CompareChart function · typescript · L225-L311 (87 LOC)
src/app/analysis/CompareAthletes.tsx
function CompareChart({ profileA, profileB }: { profileA: AthleteProfile; profileB: AthleteProfile }) {
  const eventsA = useMemo(() => getAllEvents(profileA), [profileA]);
  const eventsB = useMemo(() => getAllEvents(profileB), [profileB]);

  // Merge into a single timeline by date
  const chartData = useMemo(() => {
    const dateMap = new Map<string, { date: string; a?: number; b?: number; nameA?: string; nameB?: string }>();

    for (const e of eventsA) {
      if (!dateMap.has(e.date)) dateMap.set(e.date, { date: e.date });
      const d = dateMap.get(e.date)!;
      d.a = e.points;
      d.nameA = e.eventName;
    }
    for (const e of eventsB) {
      if (!dateMap.has(e.date)) dateMap.set(e.date, { date: e.date });
      const d = dateMap.get(e.date)!;
      d.b = e.points;
      d.nameB = e.eventName;
    }

    return [...dateMap.values()].sort((a, b) => a.date.localeCompare(b.date));
  }, [eventsA, eventsB]);

  if (chartData.length < 2) {
    return (
      <div className=
AthleteDistribution function · typescript · L18-L182 (165 LOC)
src/app/analysis/DistributionCharts.tsx
export function AthleteDistribution({
  athleteIndex,
  selectedAthlete,
}: {
  athleteIndex: AthleteIndex;
  selectedAthlete: AthleteSummary | null;
}) {
  const { data, selectedPoint } = useMemo(() => {
    const points: {
      name: string;
      forest: number;
      sprint: number;
      type: string;
      isSelected: boolean;
    }[] = [];
    let sel: { forest: number; sprint: number; name: string } | null = null;

    for (const a of Object.values(athleteIndex.athletes)) {
      const fApps = a.appearances.filter((r) => r.type.includes("forest"));
      const sApps = a.appearances.filter((r) => r.type.includes("sprint"));
      if (fApps.length === 0 || sApps.length === 0) continue;

      const fPts = Math.max(...fApps.map((r) => r.totalPoints));
      const sPts = Math.max(...sApps.map((r) => r.totalPoints));
      const isSelected = selectedAthlete?.name === a.name;

      points.push({
        name: a.name,
        forest: Math.round(fPts),
        sprint: Math.round(sPts
Powered by Repobility — scan your code at https://repobility.com
ClubDistribution function · typescript · L185-L337 (153 LOC)
src/app/analysis/DistributionCharts.tsx
export function ClubDistribution({
  clubIndex,
  expandedClub,
}: {
  clubIndex: ClubIndex;
  expandedClub: string | null;
}) {
  const data = useMemo(() => {
    return Object.values(clubIndex.clubs)
      .filter((c) => c.memberCount >= 2) // 2名以上のクラブのみ
      .map((c) => {
        const totalType = c.forestCount + c.sprintCount;
        const forestRatio = totalType > 0 ? c.forestCount / totalType : 0.5;
        return {
          name: c.name,
          members: c.memberCount,
          avgPoints: Math.round(c.avgPoints),
          active: c.activeCount,
          forestRatio,
          isSelected: c.name === expandedClub,
        };
      });
  }, [clubIndex, expandedClub]);

  // Show labels for clubs that don't overlap, prioritized by avgPoints
  const labeledClubs = useMemo(() => {
    const sorted = [...data].sort((a, b) => b.avgPoints - a.avgPoints);
    const placed: { members: number; avgPoints: number }[] = [];
    const result: typeof data = [];

    // Normalize threshol
AnalysisPage function · typescript · L9-L24 (16 LOC)
src/app/analysis/page.tsx
export default function AnalysisPage() {
  return (
    <div className="mx-auto max-w-5xl px-4 py-6">
      <div className="mb-1 flex items-center gap-2">
        <h1 className="text-2xl font-bold">分析</h1>
        <span className="rounded bg-accent/20 px-2 py-0.5 text-[10px] font-medium text-[#00e5ff]">
          JOY データ
        </span>
      </div>
      <p className="mb-6 text-xs text-muted">
        ランキングデータを元にした選手の傾向分析・特性分類・クラブ統計・選手比較
      </p>
      <AnalysisHub />
    </div>
  );
}
GET function · typescript · L11-L87 (77 LOC)
src/app/api/cron/sync-events/route.ts
export async function GET(request: Request) {
  const authHeader = request.headers.get("authorization");
  if (
    process.env.CRON_SECRET &&
    authHeader !== `Bearer ${process.env.CRON_SECRET}`
  ) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  try {
    // ---- イベント同期 ----
    const freshEvents = await scrapeEvents();

    // 既存データから座標・Lap Center情報を引き継ぎ
    const stored = new Map(
      (await readEvents()).map((e) => [e.joe_event_id, e])
    );

    for (const event of freshEvents) {
      const existing = stored.get(event.joe_event_id);
      if (existing) {
        event.lat = existing.lat;
        event.lng = existing.lng;
        event.lapcenter_event_id = existing.lapcenter_event_id;
        event.lapcenter_url = existing.lapcenter_url;
        event.recently_updated = existing.recently_updated;
        event.update_label = existing.update_label;
      }
    }

    // 座標未取得のイベントをバッチ処理(50件/回、500ms間隔)
    const coordResult = await enrichEven
GET function · typescript · L8-L52 (45 LOC)
src/app/api/cron/sync-lapcenter/route.ts
export async function GET(request: Request) {
  const authHeader = request.headers.get("authorization");
  if (
    process.env.CRON_SECRET &&
    authHeader !== `Bearer ${process.env.CRON_SECRET}`
  ) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  try {
    // Supabase から最新イベントデータを取得
    const events = (await readEvents()).map((e) => ({ ...e }));

    const beforeUnmatched = events.filter(
      (e) => !e.lapcenter_event_id
    ).length;

    const result = await matchLapCenterEvents(events);

    const afterUnmatched = events.filter(
      (e) => !e.lapcenter_event_id
    ).length;
    const newMatches = beforeUnmatched - afterUnmatched;

    // マッチ結果を Supabase に保存
    if (newMatches > 0) {
      await writeEvents(events);
    }

    return NextResponse.json({
      success: true,
      new_matches: newMatches,
      total_matched: result.matched,
      total_events: result.total,
      lc_events_fetched: result.lcEventsCount,
      synced_at: new 
ContactForm function · typescript · L8-L144 (137 LOC)
src/app/contact/ContactForm.tsx
export function ContactForm() {
  const [name, setName] = useState("");
  const [email, setEmail] = useState("");
  const [category, setCategory] = useState("general");
  const [message, setMessage] = useState("");
  const [sending, setSending] = useState(false);
  const [sent, setSent] = useState(false);
  const [error, setError] = useState("");

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!message.trim()) {
      setError("メッセージを入力してください");
      return;
    }
    if (!FORMSPREE_ID) {
      setError("フォームが設定されていません");
      return;
    }

    setError("");
    setSending(true);

    try {
      const res = await fetch(`https://formspree.io/f/${FORMSPREE_ID}`, {
        method: "POST",
        headers: { "Content-Type": "application/json", Accept: "application/json" },
        body: JSON.stringify({
          name: name || "匿名",
          email: email || "未入力",
          category,
          message,
        }),
      });

      if (res.ok) {
ContactPage function · typescript · L11-L37 (27 LOC)
src/app/contact/page.tsx
export default function ContactPage() {
  return (
    <div className="mx-auto max-w-2xl px-4 py-10">
      <Link href="/" className="mb-6 inline-flex items-center gap-1 text-xs text-muted hover:text-foreground">
        <ArrowLeft className="h-3.5 w-3.5" />
        トップに戻る
      </Link>

      <h1 className="text-2xl font-bold">お問い合わせ</h1>
      <p className="mt-2 text-sm text-muted">
        ご質問・ご要望・不具合報告など、お気軽にお送りください。
      </p>

      <div className="mt-8">
        <ContactForm />
      </div>

      <div className="mt-8 rounded-lg border border-amber-500/20 bg-amber-500/5 p-4">
        <p className="text-xs text-amber-400">
          大会に関する個別のお問い合わせは、各大会の主催者または
          <a href="https://orienteering.com/" target="_blank" rel="noopener noreferrer" className="underline hover:text-amber-300">JOY</a>
          へ直接ご連絡ください。
        </p>
      </div>
    </div>
  );
}
getDateRangeCutoff function · typescript · L28-L40 (13 LOC)
src/app/events/EventList.tsx
function getDateRangeCutoff(range: string): string {
  if (range === "all") return "";
  const now = new Date();
  switch (range) {
    case "yesterday": now.setDate(now.getDate() - 1); break;
    case "1w": now.setDate(now.getDate() - 7); break;
    case "1m": now.setMonth(now.getMonth() - 1); break;
    case "2m": now.setMonth(now.getMonth() - 2); break;
    case "3m": now.setMonth(now.getMonth() - 3); break;
    case "1y": now.setFullYear(now.getFullYear() - 1); break;
  }
  return now.toISOString().slice(0, 10);
}
EventList function · typescript · L42-L320 (279 LOC)
src/app/events/EventList.tsx
export function EventList({ events }: EventListProps) {
  const [viewMode, setViewMode] = useState<"list" | "calendar">("list");
  const [query, setQuery] = useState("");
  const [tagFilter, setTagFilter] = useState("");
  const [entryFilter, setEntryFilter] = useState("");
  const [dateRange, setDateRange] = useState("1w");
  const [currentMonth, setCurrentMonth] = useState(() => {
    const now = new Date();
    return { year: now.getFullYear(), month: now.getMonth() };
  });

  const allTags = useMemo(
    () => [...new Set(events.flatMap((e) => e.tags))].sort(),
    [events]
  );

  const filtered = useMemo(() => {
    const cutoff = getDateRangeCutoff(dateRange);
    return events
      .filter((e) => {
        if (cutoff && e.date < cutoff) return false;
        if (query) {
          const q = query.toLowerCase();
          if (!e.name.toLowerCase().includes(q) && !e.prefecture.includes(q)) return false;
        }
        if (tagFilter && !e.tags.includes(tagFilter)) return fals
If a scraper extracted this row, it came from Repobility (https://repobility.com)
generateMetadata function · typescript · L9-L13 (5 LOC)
src/app/events/[id]/page.tsx
export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { id } = await params;
  const event = sampleJOEEvents.find((e) => e.joe_event_id === parseInt(id, 10));
  return { title: event?.name ?? "イベント" };
}
EventDetailPage function · typescript · L19-L30 (12 LOC)
src/app/events/[id]/page.tsx
export default async function EventDetailPage({ params }: Props) {
  const { id } = await params;
  const eventId = parseInt(id, 10);
  const event = sampleJOEEvents.find((e) => e.joe_event_id === eventId);

  if (event) {
    redirect(event.joe_url);
  }

  // JOE の URL パターンにフォールバック
  redirect(`https://japan-o-entry.com/event/view/${id}`);
}
EventsPage function · typescript · L13-L39 (27 LOC)
src/app/events/page.tsx
export default async function EventsPage() {
  const events = await readEvents();

  return (
    <div className="mx-auto max-w-5xl px-4 py-6">
      <div className="mb-1 flex items-center gap-2">
        <h1 className="text-2xl font-bold">イベント</h1>
        <span className="rounded bg-accent/20 px-2 py-0.5 text-[10px] font-medium text-[#00e5ff]">
          JOY 連携
        </span>
      </div>
      <p className="mb-3 text-xs text-muted">
        JOY から日次自動取得。{events.length} 件のイベント
      </p>
      <div className="mb-6 rounded-lg border border-amber-500/20 bg-amber-500/5 px-3 py-2">
        <p className="flex items-center gap-1.5 text-xs text-muted">
          <span className="inline-flex items-center gap-0.5 rounded bg-amber-500/15 px-2 py-0.5 text-[10px] font-medium text-amber-400">
            <Bell className="h-2.5 w-2.5" />
            更新
          </span>
          が付いている大会は JOY の更新履歴に掲載されています
        </p>
      </div>
      <EventList events={events} />
    </div>
  );
}
page 1 / 3next ›