Function bodies 121 total
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(cnormalize 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 extractSigniPowered 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 || "";
// 場所: リンクの後のテキストノード(都道府県や会場名)
coscrapeArchive 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 = exparseDate 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;
iIf 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(allDRepobility 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 || searAthleteDetail 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">
FStatsCards 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)) dateMapRecentEvents 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-grRepobility'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 = calcRecentForCompareRow 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(sPtsPowered 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 thresholAnalysisPage 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 enrichEvenGET 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 falsIf 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 ›