← back to kodanatlas__trails-jp

Function bodies 121 total

All specs Real LLM only Function bodies
RootLayout function · typescript · L26-L42 (17 LOC)
src/app/layout.tsx
export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="ja">
      <body
        className={`${geistSans.variable} ${geistMono.variable} flex min-h-screen flex-col font-sans antialiased`}
      >
        <Header />
        <main className="flex-1">{children}</main>
        <Footer />
      </body>
    </html>
  );
}
EditButton function · typescript · L12-L27 (16 LOC)
src/app/maps/[id]/MapDetailClient.tsx
export function EditButton({ map }: Props) {
  const [editing, setEditing] = useState(false);

  return (
    <>
      <button
        onClick={() => setEditing(true)}
        className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-xs font-medium text-muted transition-colors hover:border-primary/30 hover:text-primary"
      >
        <Pencil className="h-3.5 w-3.5" />
        編集
      </button>
      {editing && <MapEditor map={map} onClose={() => setEditing(false)} />}
    </>
  );
}
updateBoundsOnMap function · typescript · L139-L176 (38 LOC)
src/app/maps/[id]/MapEditor.tsx
  function updateBoundsOnMap(mlMap: any, b: typeof bounds) {
    const coords = [
      [b.west, b.north],
      [b.east, b.north],
      [b.east, b.south],
      [b.west, b.south],
      [b.west, b.north],
    ];
    const data = {
      type: "Feature" as const,
      properties: {},
      geometry: { type: "Polygon" as const, coordinates: [coords] },
    };

    if (mlMap.getSource("edit-bounds")) {
      mlMap.getSource("edit-bounds").setData(data);
    } else {
      mlMap.addSource("edit-bounds", { type: "geojson", data });
      mlMap.addLayer({
        id: "edit-bounds-fill",
        type: "fill",
        source: "edit-bounds",
        paint: { "fill-color": "#f97316", "fill-opacity": 0.1 },
      });
      mlMap.addLayer({
        id: "edit-bounds-line",
        type: "line",
        source: "edit-bounds",
        paint: { "line-color": "#f97316", "line-width": 2, "line-dasharray": [3, 2] },
      });
    }

    // Update overlay image bounds if exists
    if (mlMap.getSource(
MapViewer function · typescript · L18-L117 (100 LOC)
src/app/maps/[id]/MapViewer.tsx
export function MapViewer({ map }: MapViewerProps) {
  const containerRef = useRef<HTMLDivElement>(null);
  const mapRef = useRef<{ remove: () => void } | null>(null);
  const [opacity, setOpacity] = useState(0.7);
  const [loaded, setLoaded] = useState(false);
  const [baseMap, setBaseMap] = useState("gsi-std");
  const [showPicker, setShowPicker] = useState(false);

  useEffect(() => {
    if (!containerRef.current) return;
    let cancelled = false;

    import("maplibre-gl").then((mod) => {
      if (cancelled || !containerRef.current) return;
      const maplibregl = mod.default;
      const base = BASE_MAPS.find((b) => b.key === baseMap) ?? BASE_MAPS[0];

      const mlMap = new maplibregl.Map({
        container: containerRef.current,
        style: {
          version: 8,
          sources: {
            base: { type: "raster", tiles: [base.url], tileSize: 256, attribution: base.attr },
          },
          layers: [{ id: "base-layer", type: "raster", source: "base" }],
     
generateMetadata function · typescript · L15-L19 (5 LOC)
src/app/maps/[id]/page.tsx
export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { id } = await params;
  const map = sampleMaps.find((m) => m.id === id);
  return { title: map?.name ?? "地図が見つかりません" };
}
MapDetailPage function · typescript · L21-L131 (111 LOC)
src/app/maps/[id]/page.tsx
export default async function MapDetailPage({ params }: Props) {
  const { id } = await params;
  const map = sampleMaps.find((m) => m.id === id);

  if (!map) {
    return (
      <div className="mx-auto max-w-5xl px-4 py-20 text-center">
        <h1 className="text-xl font-bold">地図が見つかりません</h1>
        <Link href="/maps" className="mt-4 inline-block text-sm text-primary hover:underline">
          地図一覧に戻る
        </Link>
      </div>
    );
  }

  const events = await readEvents();
  const matchedEvents = findEventsForMap(map, events);

  return (
    <div className="mx-auto max-w-5xl px-4 py-6">
      <Link href="/maps" className="mb-5 inline-flex items-center gap-1 text-xs text-muted hover:text-foreground">
        <ArrowLeft className="h-3.5 w-3.5" />
        地図データベース
      </Link>

      <div className="flex items-center justify-between">
        <div className="flex items-center gap-2">
          <h1 className="text-2xl font-bold">{map.name}</h1>
          {map.isSample && (
   
MapsLayout function · typescript · L1-L7 (7 LOC)
src/app/maps/layout.tsx
export default function MapsLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return <>{children}</>;
}
Repobility's GitHub App fixes findings like these · https://github.com/apps/repobility-bot
getTerrainColor function · typescript · L24-L32 (9 LOC)
src/app/maps/MapBrowser.tsx
function getTerrainColor(t: TerrainType): string {
  switch (t) {
    case "forest": return "#FF00FF";
    case "park": return "#00FFF6";
    case "urban": return "#8a8ab0";
    case "sand": return "#afcc21";
    case "mixed": return "#00FF18";
  }
}
boundsToPolygon function · typescript · L41-L56 (16 LOC)
src/app/maps/MapBrowser.tsx
function boundsToPolygon(bounds: OrienteeringMap["bounds"]): GeoJSON.Feature<GeoJSON.Polygon> {
  return {
    type: "Feature",
    properties: {},
    geometry: {
      type: "Polygon",
      coordinates: [[
        [bounds.west, bounds.north],
        [bounds.east, bounds.north],
        [bounds.east, bounds.south],
        [bounds.west, bounds.south],
        [bounds.west, bounds.north],
      ]],
    },
  };
}
MapsPage function · typescript · L9-L11 (3 LOC)
src/app/maps/page.tsx
export default function MapsPage() {
  return <MapBrowser maps={sampleMaps} />;
}
Home function · typescript · L9-L248 (240 LOC)
src/app/page.tsx
export default function Home() {
  const allEvents = eventsJson as JOEEvent[];
  const now = new Date().toISOString().slice(0, 10);
  const upcomingEvents = allEvents
    .filter((e) => e.date >= now)
    .sort((a, b) => a.date.localeCompare(b.date))
    .slice(0, 4);

  const rankingAthletes = new Set(
    Object.values(rankingsJson as Record<string, { athlete_name: string }[]>)
      .flat()
      .map((e) => e.athlete_name)
  ).size;

  const latestMaps = sampleMaps.slice(0, 6);

  return (
    <div>
      {/* Hero */}
      <section className="relative overflow-hidden border-b border-border bg-surface py-16 sm:py-24">
        <div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-accent/5" />
        <div className="relative mx-auto max-w-6xl px-4 text-center">
          <h1 className="text-4xl font-bold tracking-tight sm:text-5xl lg:text-6xl">
            日本の
            <span className="text-primary">オリエンテーリング</span>
            を、
            <br cl
RankingsPage function · typescript · L10-L25 (16 LOC)
src/app/rankings/page.tsx
export default function RankingsPage() {
  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">
        JOY から週次(水曜 03:00 JST)自動取得。
      </p>
      <RankingView rankingConfigs={RANKING_CONFIGS} />
    </div>
  );
}
RankBadge function · typescript · L18-L23 (6 LOC)
src/app/rankings/RankingView.tsx
function RankBadge({ rank }: { rank: number }) {
  if (rank === 1) return <div className="flex h-6 w-6 items-center justify-center rounded-full bg-gradient-to-br from-yellow-300 to-yellow-600 text-[10px] font-bold text-white shadow"><Trophy className="h-3 w-3" /></div>;
  if (rank === 2) return <div className="flex h-6 w-6 items-center justify-center rounded-full bg-gradient-to-br from-gray-300 to-gray-500 text-[10px] font-bold text-white shadow"><Medal className="h-3 w-3" /></div>;
  if (rank === 3) return <div className="flex h-6 w-6 items-center justify-center rounded-full bg-gradient-to-br from-amber-400 to-amber-700 text-[10px] font-bold text-white shadow"><Medal className="h-3 w-3" /></div>;
  return <div className="flex h-6 w-6 items-center justify-center text-xs text-muted">{rank}</div>;
}
PointsBar function · typescript · L25-L40 (16 LOC)
src/app/rankings/RankingView.tsx
function PointsBar({ points, maxPoints }: { points: number; maxPoints: number }) {
  const width = maxPoints > 0 ? (points / maxPoints) * 100 : 0;
  return (
    <div className="flex items-center gap-2">
      <div className="h-1.5 flex-1 overflow-hidden rounded-full bg-white/5">
        <div
          className="h-full rounded-full bg-gradient-to-r from-primary to-[#00e5ff]"
          style={{ width: `${width}%` }}
        />
      </div>
      <span className="w-16 text-right font-mono text-sm font-bold text-primary">
        {points.toLocaleString(undefined, { minimumFractionDigits: 1, maximumFractionDigits: 1 })}
      </span>
    </div>
  );
}
dataUrl function · typescript · L43-L45 (3 LOC)
src/app/rankings/RankingView.tsx
function dataUrl(type: string, className: string): string {
  return `/data/rankings/${type}_${className}.json`;
}
Repobility — same analyzer, your code, free for public repos · /scan/
RankingView function · typescript · L47-L286 (240 LOC)
src/app/rankings/RankingView.tsx
export function RankingView({ rankingConfigs }: RankingViewProps) {
  const [selectedType, setSelectedType] = useState(rankingConfigs[0]?.type ?? "");
  const [selectedClass, setSelectedClass] = useState(rankingConfigs[0]?.classes[0]?.name ?? "");
  const [searchQuery, setSearchQuery] = useState("");
  const [expandedRank, setExpandedRank] = useState<number | null>(null);
  const [hideInactive, setHideInactive] = useState(false);

  // データキャッシュ: fetch 済みのカテゴリをメモリに保持
  const [cache, setCache] = useState<Record<string, JOERankingEntry[]>>({});
  const [loading, setLoading] = useState(false);

  const dataKey = `${selectedType}:${selectedClass}`;
  const rankings = cache[dataKey] ?? [];
  const maxPoints = rankings[0]?.total_points ?? 1;

  const currentConfig = rankingConfigs.find((c) => c.type === selectedType);

  // カテゴリ変更時にデータを fetch
  const fetchData = useCallback(async (type: string, className: string) => {
    const key = `${type}:${className}`;
    if (cache[key]) return; // キャッシ
CreateEventPage function · typescript · L9-L11 (3 LOC)
src/app/tracking/create/page.tsx
export default function CreateEventPage() {
  return <CreateEventForm />;
}
generateStaticParams function · typescript · L9-L11 (3 LOC)
src/app/tracking/[eventId]/page.tsx
export function generateStaticParams() {
  return sampleTrackingEvents.map((e) => ({ eventId: e.id }));
}
generateMetadata function · typescript · L13-L19 (7 LOC)
src/app/tracking/[eventId]/page.tsx
export async function generateMetadata({ params }: Props) {
  const { eventId } = await params;
  const event = sampleTrackingEvents.find((e) => e.id === eventId);
  return {
    title: event ? `${event.title} - GPS追跡` : "GPS追跡",
  };
}
TrackingEventPage function · typescript · L21-L27 (7 LOC)
src/app/tracking/[eventId]/page.tsx
export default async function TrackingEventPage({ params }: Props) {
  const { eventId } = await params;
  const event = sampleTrackingEvents.find((e) => e.id === eventId);
  if (!event) notFound();

  return <TrackingView event={event} />;
}
formatTime function · typescript · L14-L20 (7 LOC)
src/app/tracking/[eventId]/TrackingView.tsx
function formatTime(seconds: number): string {
  const h = Math.floor(seconds / 3600);
  const m = Math.floor((seconds % 3600) / 60);
  const s = Math.floor(seconds % 60);
  if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
  return `${m}:${String(s).padStart(2, "0")}`;
}
getTrailUpToTime function · typescript · L42-L54 (13 LOC)
src/app/tracking/[eventId]/TrackingView.tsx
function getTrailUpToTime(track: TrackPoint[], t: number): [number, number][] {
  const coords: [number, number][] = [];
  for (const p of track) {
    if (p.time > t) break;
    coords.push([p.lng, p.lat]);
  }
  // Add interpolated current position
  const pos = getPositionAtTime(track, t);
  if (pos && coords.length > 0) {
    coords.push([pos.lng, pos.lat]);
  }
  return coords;
}
placeColor function · typescript · L57-L62 (6 LOC)
src/app/tracking/[eventId]/TrackingView.tsx
function placeColor(place: number): string {
  if (place === 1) return "#FFD700";
  if (place === 2) return "#C0C0C0";
  if (place === 3) return "#CD7F32";
  return "#8a9bb0";
}
Same scanner, your repo: https://repobility.com — Repobility
TrackingListPage function · typescript · L11-L283 (273 LOC)
src/app/tracking/page.tsx
export default function TrackingListPage() {
  const events = sampleTrackingEvents;

  return (
    <div className="mx-auto max-w-5xl px-4 py-6">
      <div className="mb-1 flex items-center justify-between">
        <div className="flex items-center gap-2">
          <h1 className="text-2xl font-bold">ライブGPS追跡</h1>
          <span className="rounded bg-green-500/15 px-2 py-0.5 text-[10px] font-medium text-green-400">
            Route Analysis
          </span>
        </div>
        <Link
          href="/tracking/create"
          className="flex flex-shrink-0 items-center gap-1.5 whitespace-nowrap rounded-lg bg-primary px-3 py-2 text-xs font-medium text-white transition-colors hover:bg-primary-dark sm:px-4"
        >
          <Plus className="h-3.5 w-3.5" />
          <span className="hidden sm:inline">イベント</span>作成
        </Link>
      </div>
      <p className="mb-8 text-xs text-muted">
        大会中のリアルタイムGPS追跡とルート分析。GPXファイルのアップロードにも対応
      </p>

      {/* Live Events */}
     
UploadPage function · typescript · L10-L18 (9 LOC)
src/app/upload/page.tsx
export default function UploadPage() {
  return (
    <div className="mx-auto max-w-3xl px-4 py-6">
      <AuthGuard>
        <UploadWizard />
      </AuthGuard>
    </div>
  );
}
extractAuthUser function · typescript · L14-L19 (6 LOC)
src/components/AuthGuard.tsx
function extractAuthUser(u: SupabaseUser): AuthUser {
  return {
    email: u.email!,
    displayName: u.user_metadata?.display_name ?? u.email!,
  };
}
AuthGuard function · typescript · L24-L283 (260 LOC)
src/components/AuthGuard.tsx
export function AuthGuard({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<AuthUser | null | "loading">("loading");
  const [step, setStep] = useState<"form" | "confirm">("form");
  const [email, setEmail] = useState("");
  const [displayName, setDisplayName] = useState("");
  const [sending, setSending] = useState(false);
  const [code, setCode] = useState("");
  const [verifying, setVerifying] = useState(false);
  const [error, setError] = useState("");

  useEffect(() => {
    // 既存セッション確認
    supabase.auth.getSession().then(({ data: { session } }) => {
      if (session?.user) {
        setUser(extractAuthUser(session.user));
      } else {
        setUser(null);
      }
    });

    // 認証状態の変更を監視
    const {
      data: { subscription },
    } = supabase.auth.onAuthStateChange((_event, session) => {
      if (session?.user) {
        setUser(extractAuthUser(session.user));
      } else {
        setUser(null);
      }
    });

    return () => subs
getCurrentUser function · typescript · L286-L308 (23 LOC)
src/components/AuthGuard.tsx
export function getCurrentUser(): AuthUser | null {
  if (typeof window === "undefined") return null;

  try {
    const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
    if (!url) return null;
    const projectRef = new URL(url).hostname.split(".")[0];
    const key = `sb-${projectRef}-auth-token`;
    const raw = localStorage.getItem(key);
    if (!raw) return null;

    const parsed = JSON.parse(raw);
    const u = parsed?.user;
    if (!u?.email) return null;

    return {
      email: u.email,
      displayName: u.user_metadata?.display_name ?? u.email,
    };
  } catch {
    return null;
  }
}
Footer function · typescript · L3-L19 (17 LOC)
src/components/Footer.tsx
export function Footer() {
  return (
    <footer className="border-t border-white/10 bg-[#1a2332]">
      <div className="mx-auto flex max-w-6xl flex-col items-center gap-3 px-4 py-6 text-xs text-white/40 sm:flex-row sm:justify-between">
        <p>&copy; 2026 trails.jp</p>
        <div className="flex gap-6">
          <Link href="/about" className="transition-colors hover:text-white/70">
            このサイトについて
          </Link>
          <Link href="/contact" className="transition-colors hover:text-white/70">
            お問い合わせ
          </Link>
        </div>
      </div>
    </footer>
  );
}
Header function · typescript · L18-L92 (75 LOC)
src/components/Header.tsx
export function Header() {
  const [isOpen, setIsOpen] = useState(false);
  const pathname = usePathname();

  return (
    <header className="sticky top-0 z-50 h-14 border-b border-white/10 bg-[#1a2332]">
      <div className="mx-auto flex h-full max-w-[1920px] items-center justify-between px-4">
        <Link href="/" className="flex items-center gap-2 text-lg font-bold text-white">
          <Compass className="h-5 w-5 text-[#f97316]" />
          <span>trails<span className="text-[#f97316]">.jp</span></span>
        </Link>

        <nav className="hidden gap-1 md:flex">
          {navItems.map((item) => (
            <Link
              key={item.href}
              href={item.href}
              className={cn(
                "rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
                pathname === item.href
                  ? "bg-white/15 text-white"
                  : "text-white/60 hover:bg-white/10 hover:text-white"
              )}
            >
        
loadAthleteDetail function · typescript · L18-L78 (61 LOC)
src/lib/analysis/utils.ts
export async function loadAthleteDetail(
  summary: AthleteSummary
): Promise<AthleteProfile> {
  // どのファイルを読む必要があるか (type_className の重複排除)
  const fileKeys = new Set(
    summary.appearances.map((a) => `${a.type}_${a.className}`)
  );

  const rankings: RankingAppearance[] = [];

  await Promise.all(
    [...fileKeys].map(async (key) => {
      try {
        const res = await fetch(`/data/rankings/${key}.json`);
        if (!res.ok) return;
        const entries: JOERankingEntry[] = await res.json();
        const entry = entries.find((e) => e.athlete_name === summary.name);
        if (!entry) return;

        const parts = key.split("_");
        // type は最初の2つ (e.g. "age_forest"), className はそれ以降
        let type: string;
        let className: string;
        if (key.startsWith("elite_forest_")) {
          type = "elite_forest";
          className = key.slice("elite_forest_".length);
        } else if (key.startsWith("elite_sprint_")) {
          type = "elite_sprint";
         
If a scraper extracted this row, it came from Repobility (https://repobility.com)
calcConsistency function · typescript · L83-L91 (9 LOC)
src/lib/analysis/utils.ts
export function calcConsistency(events: EventScore[]): number {
  if (events.length < 2) return 0;
  const points = events.map((e) => e.points);
  const mean = points.reduce((a, b) => a + b, 0) / points.length;
  if (mean === 0) return 0;
  const variance = points.reduce((sum, p) => sum + (p - mean) ** 2, 0) / points.length;
  const cv = Math.sqrt(variance) / mean;
  return Math.round(Math.max(0, Math.min(100, (1 - cv / 0.3) * 100)));
}
calcRecentForm function · typescript · L96-L107 (12 LOC)
src/lib/analysis/utils.ts
export function calcRecentForm(events: EventScore[]): number {
  if (events.length < 2) return 0;
  const sorted = [...events]
    .filter((e) => e.date)
    .sort((a, b) => b.date.localeCompare(a.date));
  if (sorted.length < 2) return 0;
  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);
}
getAllEvents function · typescript · L112-L125 (14 LOC)
src/lib/analysis/utils.ts
export function getAllEvents(profile: AthleteProfile): EventScore[] {
  const seen = new Set<string>();
  const all: EventScore[] = [];
  for (const r of profile.rankings) {
    for (const e of r.events) {
      const key = `${e.date}:${e.eventName}`;
      if (!seen.has(key) && e.date) {
        seen.add(key);
        all.push(e);
      }
    }
  }
  return all.sort((a, b) => a.date.localeCompare(b.date));
}
typeLabel function · typescript · L130-L137 (8 LOC)
src/lib/analysis/utils.ts
export function typeLabel(type: AthleteSummary["type"]): string {
  switch (type) {
    case "sprinter": return "スプリンター";
    case "forester": return "フォレスター";
    case "allrounder": return "オールラウンダー";
    default: return "—";
  }
}
rankingTypeLabel function · typescript · L142-L150 (9 LOC)
src/lib/analysis/utils.ts
export function rankingTypeLabel(type: string): string {
  const labels: Record<string, string> = {
    elite_forest: "エリートフォレスト",
    elite_sprint: "エリートスプリント",
    age_forest: "年齢別フォレスト",
    age_sprint: "年齢別スプリント",
  };
  return labels[type] ?? type;
}
getBestRanks function · typescript · L155-L182 (28 LOC)
src/lib/analysis/utils.ts
export function getBestRanks(appearances: RankingRef[]) {
  let forestBest = Infinity;
  let sprintBest = Infinity;
  let forestPoints = 0;
  let sprintPoints = 0;

  for (const r of appearances) {
    if (r.type.includes("forest")) {
      if (r.rank < forestBest) {
        forestBest = r.rank;
        forestPoints = r.totalPoints;
      }
    }
    if (r.type.includes("sprint")) {
      if (r.rank < sprintBest) {
        sprintBest = r.rank;
        sprintPoints = r.totalPoints;
      }
    }
  }

  return {
    forestRank: forestBest === Infinity ? null : forestBest,
    forestPoints,
    sprintRank: sprintBest === Infinity ? null : sprintBest,
    sprintPoints,
  };
}
ensureBucket function · typescript · L11-L15 (5 LOC)
src/lib/events-store.ts
async function ensureBucket(): Promise<void> {
  if (bucketReady) return;
  await supabaseAdmin.storage.createBucket(BUCKET, { public: false });
  bucketReady = true;
}
readEvents function · typescript · L21-L36 (16 LOC)
src/lib/events-store.ts
export async function readEvents(): Promise<JOEEvent[]> {
  try {
    const { data, error } = await supabaseAdmin.storage
      .from(BUCKET)
      .download(FILE_PATH);

    if (!error && data) {
      const text = await data.text();
      return JSON.parse(text) as JOEEvent[];
    }
  } catch {
    // Supabase未設定 or ファイル未作成 → フォールバック
  }

  return eventsJson as JOEEvent[];
}
Repobility's GitHub App fixes findings like these · https://github.com/apps/repobility-bot
writeEvents function · typescript · L41-L59 (19 LOC)
src/lib/events-store.ts
export async function writeEvents(events: JOEEvent[]): Promise<void> {
  await ensureBucket();

  const blob = new Blob([JSON.stringify(events)], {
    type: "application/json",
  });

  const { error } = await supabaseAdmin.storage
    .from(BUCKET)
    .upload(FILE_PATH, blob, {
      upsert: true,
      contentType: "application/json",
    });

  if (error) {
    console.error("Failed to write events to Supabase Storage:", error.message);
    throw error;
  }
}
isPointInBounds function · typescript · L10-L22 (13 LOC)
src/lib/map-event-matcher.ts
export function isPointInBounds(
  lat: number,
  lng: number,
  bounds: OrienteeringMap["bounds"],
  marginDeg = DEFAULT_MARGIN_DEG
): boolean {
  return (
    lat >= bounds.south - marginDeg &&
    lat <= bounds.north + marginDeg &&
    lng >= bounds.west - marginDeg &&
    lng <= bounds.east + marginDeg
  );
}
findEventsForMap function · typescript · L28-L40 (13 LOC)
src/lib/map-event-matcher.ts
export function findEventsForMap(
  map: OrienteeringMap,
  events: JOEEvent[],
  marginDeg = DEFAULT_MARGIN_DEG
): JOEEvent[] {
  return events
    .filter(
      (e): e is JOEEvent & { lat: number; lng: number } =>
        typeof e.lat === "number" && typeof e.lng === "number"
    )
    .filter((e) => isPointInBounds(e.lat, e.lng, map.bounds, marginDeg))
    .sort((a, b) => b.date.localeCompare(a.date));
}
scrapeEvents function · typescript · L28-L35 (8 LOC)
src/lib/scraper/events.ts
export async function scrapeEvents(): Promise<JOEEvent[]> {
  const res = await fetch(BASE_URL, {
    headers: { "User-Agent": "trails.jp/1.0 (event sync)" },
    next: { revalidate: 0 },
  });
  const html = await res.text();
  return parseEventList(html);
}
scrapeArchive function · typescript · L40-L48 (9 LOC)
src/lib/scraper/events.ts
export async function scrapeArchive(year?: number): Promise<JOEEvent[]> {
  const url = year ? `${BASE_URL}/event/archive?year=${year}` : `${BASE_URL}/event/archive`;
  const res = await fetch(url, {
    headers: { "User-Agent": "trails.jp/1.0 (event sync)" },
    next: { revalidate: 0 },
  });
  const html = await res.text();
  return parseEventList(html);
}
parseEventList function · typescript · L50-L111 (62 LOC)
src/lib/scraper/events.ts
function parseEventList(html: string): JOEEvent[] {
  const $ = cheerio.load(html);
  const events: JOEEvent[] = [];

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

    // Extract link and event ID
    const link = $row.find("a[href*='/event/view/']");
    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);

    // Date from date-sort attribute or first cell
    const dateSort = $row.attr("date-sort") ?? "";
    const dateText = cells.eq(0).text().trim();

    // Parse date range (e.g., "2026/3/14-15" or "2026/3/14")
    const { date, end_date } = parseDate(dateSort || dateText);

    // Event name
    const name = link.text().trim();

    // Location / Prefecture
    const locationText = cells.eq(2)?.text().trim() ?? "";

    // Entry status
 
scrapeEventCoordinates function · typescript · L143-L145 (3 LOC)
src/lib/scraper/events.ts
export async function scrapeEventCoordinates(
  joeUrl: string
): Promise<{ lat: number; lng: number } | null> {
enrichEventsWithCoordinates function · typescript · L167-L171 (5 LOC)
src/lib/scraper/events.ts
export async function enrichEventsWithCoordinates(
  events: JOEEvent[],
  batchSize = 50,
  delayMs = 500
): Promise<{ enriched: number; skipped: number; failed: number }> {
Repobility — same analyzer, your code, free for public repos · /scan/
fetchLapCenterEvents function · typescript · L15-L55 (41 LOC)
src/lib/scraper/lapcenter.ts
export async function fetchLapCenterEvents(year: number): Promise<LapCenterEvent[]> {
  const url = `${BASE_URL}/index.jsp?year=${year}`;
  const res = await fetch(url, {
    headers: { "User-Agent": "trails.jp/1.0 (lapcenter sync)" },
  });
  if (!res.ok) return [];

  const html = await res.text();
  const $ = cheerio.load(html);
  const events: LapCenterEvent[] = [];
  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(currentMonth).padStart(2, "0")}-${String(day).padStart(2, "0")}`;

    tds.eq(2).find("
normalize function · typescript · L71-L245 (175 LOC)
src/lib/scraper/lapcenter.ts
function normalize(name: string): string {
  let s = name;
  s = s.replace(/[A-Za-z0-9]/g, (c) =>
    String.fromCharCode(c.charCodeAt(0) - 0xfee0)
  );
  s = s.replace(/【[^】]*】/g, "");
  s = s.replace(/第\s*[0-9一二三四五六七八九十百千]+\s*回/g, "");
  s = s.replace(/(令和|平成|昭和)\s*[0-9一二三四五六七八九十]+\s*年度?/g, "");
  s = s.replace(/20\d{2}年度?/g, "");
  s = s.replace(/20\d{6}/g, "");
  s = s.replace(/[((][^))]*[))]/g, "");
  s = s.replace(/[・\-\s &&「」『』【】〜~//\\.,、。!!??::;;##@@++==__<><>'"'"'"^`~||{}\[\][]]/g, " ");
  return s.trim();
}

function isStopRelated(token: string): boolean {
  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;
}

function extractSignificantTokens(normalizedName: string): string[] {
  return normalizedName.split(/\s+/).filter((t) => t.length >= 3 && !isStopRelated(t));
}

function coreString(normalizedName: string): string {
  let s = normalizedName.replace(/\s+/g, "");
isStopRelated function · typescript · L86-L92 (7 LOC)
src/lib/scraper/lapcenter.ts
function isStopRelated(token: string): boolean {
  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;
}
‹ prevpage 2 / 3next ›