← back to kenta-yos__climbing-schedule-app

Function bodies 116 total

All specs Real LLM only Function bodies
getTodayJST function · typescript · L4-L8 (5 LOC)
app/api/announcements/route.ts
function getTodayJST(): string {
  return new Date().toLocaleDateString("ja-JP", { timeZone: "Asia/Tokyo" })
    .replace(/\//g, "-")
    .replace(/(\d+)-(\d+)-(\d+)/, (_, y, m, d) => `${y}-${m.padStart(2, "0")}-${d.padStart(2, "0")}`);
}
GET function · typescript · L10-L31 (22 LOC)
app/api/announcements/route.ts
export async function GET(request: NextRequest) {
  try {
    const supabase = createClient();
    const { searchParams } = new URL(request.url);
    const all = searchParams.get("all") === "true";

    let query = supabase
      .from("release_announcements")
      .select("*")
      .order("created_at", { ascending: false });

    if (!all) {
      query = query.gte("display_until", getTodayJST());
    }

    const { data, error } = await query;
    if (error) return NextResponse.json({ error: error.message }, { status: 500 });
    return NextResponse.json(data || []);
  } catch {
    return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
  }
}
POST function · typescript · L33-L49 (17 LOC)
app/api/announcements/route.ts
export async function POST(request: NextRequest) {
  try {
    const supabase = createClient();
    const body = await request.json();
    const { content, display_until, created_by } = body;
    if (!content || !display_until || !created_by) {
      return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
    }
    const { error } = await supabase
      .from("release_announcements")
      .insert({ content, display_until, created_by });
    if (error) return NextResponse.json({ error: error.message }, { status: 500 });
    return NextResponse.json({ success: true }, { status: 201 });
  } catch {
    return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
  }
}
DELETE function · typescript · L51-L63 (13 LOC)
app/api/announcements/route.ts
export async function DELETE(request: NextRequest) {
  try {
    const supabase = createClient();
    const { searchParams } = new URL(request.url);
    const id = searchParams.get("id");
    if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
    const { error } = await supabase.from("release_announcements").delete().eq("id", id);
    if (error) return NextResponse.json({ error: error.message }, { status: 500 });
    return NextResponse.json({ success: true });
  } catch {
    return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
  }
}
GET function · typescript · L4-L13 (10 LOC)
app/api/gyms/route.ts
export async function GET() {
  try {
    const supabase = createClient();
    const { data, error } = await supabase.from("gym_master").select("*").order("gym_name");
    if (error) return NextResponse.json({ error: error.message }, { status: 500 });
    return NextResponse.json(data);
  } catch {
    return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
  }
}
POST function · typescript · L15-L25 (11 LOC)
app/api/gyms/route.ts
export async function POST(request: NextRequest) {
  try {
    const supabase = createClient();
    const body = await request.json();
    const { error } = await supabase.from("gym_master").insert(body);
    if (error) return NextResponse.json({ error: error.message }, { status: 500 });
    return NextResponse.json({ success: true }, { status: 201 });
  } catch {
    return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
  }
}
GET function · typescript · L4-L42 (39 LOC)
app/api/logs/route.ts
export async function GET(request: NextRequest) {
  try {
    const supabase = createClient();
    const { searchParams } = new URL(request.url);
    const user = searchParams.get("user");
    const mode = searchParams.get("mode"); // "home" のときは絞り込み取得

    if (mode === "home") {
      // トップページ用:今日〜3週間後の予定 + 今月の実績のみ
      const now = new Date();
      const toJST = (d: Date) =>
        d.toLocaleDateString("ja-JP", { timeZone: "Asia/Tokyo" })
          .replace(/\//g, "-")
          .replace(/(\d+)-(\d+)-(\d+)/, (_, y, m, day) => `${y}-${m.padStart(2,"0")}-${day.padStart(2,"0")}`);
      const today = toJST(now);
      const cutoff = toJST(new Date(now.getTime() + 21 * 24 * 60 * 60 * 1000));
      // 先月1日から取得(ランキングの先月タブ用)
      const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
      const monthStart = `${lastMonth.getFullYear()}-${String(lastMonth.getMonth() + 1).padStart(2, "0")}-01`;

      const [plansRes, logsRes] = await Promise.all([
        supabase.from("cl
Want this analysis on your repo? https://repobility.com/scan/
POST function · typescript · L44-L61 (18 LOC)
app/api/logs/route.ts
export async function POST(request: NextRequest) {
  try {
    const supabase = createClient();
    const body = await request.json();
    const { error } = await supabase.from("climbing_logs").insert(body);
    if (error) return NextResponse.json({ error: error.message }, { status: 500 });

    // ログ登録のアクション記録(予定 or 実績)
    if (body.user && body.type) {
      const action = body.type === "予定" ? "plan_created" : "log_created";
      supabase.from("page_views").insert({ user_name: body.user, page: "home", action }).then(() => {});
    }

    return NextResponse.json({ success: true }, { status: 201 });
  } catch {
    return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
  }
}
toJSTDate function · typescript · L12-L17 (6 LOC)
app/(app)/admin/analytics/page.tsx
function toJSTDate(iso: string): string {
  const parts = new Date(iso)
    .toLocaleDateString("ja-JP", { timeZone: "Asia/Tokyo" })
    .split("/");
  return `${parts[0]}-${parts[1].padStart(2, "0")}-${parts[2].padStart(2, "0")}`;
}
lastNDays function · typescript · L20-L29 (10 LOC)
app/(app)/admin/analytics/page.tsx
function lastNDays(n: number): string[] {
  return Array.from({ length: n }, (_, i) => {
    const d = new Date();
    d.setDate(d.getDate() - (n - 1 - i));
    const parts = d
      .toLocaleDateString("ja-JP", { timeZone: "Asia/Tokyo" })
      .split("/");
    return `${parts[0]}-${parts[1].padStart(2, "0")}-${parts[2].padStart(2, "0")}`;
  });
}
AnalyticsPage function · typescript · L31-L211 (181 LOC)
app/(app)/admin/analytics/page.tsx
export default async function AnalyticsPage() {
  const cookieStore = cookies();
  const userName = cookieStore.get("user_name")?.value;
  if (!userName) notFound();

  const supabase = createClient();
  const decodedUser = decodeURIComponent(userName);

  // UUIDでアドミンユーザーを確認
  const { data: adminUser } = await supabase
    .from("users")
    .select("user_name")
    .eq("id", ADMIN_USER_ID)
    .single();

  if (!adminUser || adminUser.user_name !== decodedUser) {
    notFound();
  }

  const adminName = adminUser.user_name;

  // 過去30日のカットオフ
  const cutoffDate = new Date();
  cutoffDate.setDate(cutoffDate.getDate() - 30);
  const cutoff = cutoffDate.toISOString();
  const cutoffDateStr = cutoffDate.toISOString().slice(0, 10);

  // 過去48時間のカットオフ
  const cutoff48h = new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString();

  const [accessLogsRes, pageViewsRes, climbingLogsRes, recentLogsRes] = await Promise.all([
    supabase
      .from("access_logs")
      .select("user_name, create
AdminPage function · typescript · L12-L50 (39 LOC)
app/(app)/admin/page.tsx
export default async function AdminPage() {
  const cookieStore = cookies();
  const userName = cookieStore.get("user_name")?.value;
  if (!userName) redirect("/");

  const supabase = createClient();

  // 1年以上前のセットスケジュールを自動削除(サイレント)
  const oneYearAgo = new Date();
  oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
  const cutoff = oneYearAgo.toISOString().slice(0, 10);
  await supabase.from("set_schedules").delete().lt("start_date", cutoff);

  const decodedUser = decodeURIComponent(userName);

  const [gymsRes, areasRes, schedulesRes, adminRes, announcementsRes] = await Promise.all([
    supabase.from("gym_master").select("*").order("gym_name"),
    supabase.from("area_master").select("*").order("major_area"),
    supabase.from("set_schedules").select("*").order("start_date", { ascending: false }),
    supabase.from("users").select("user_name").eq("id", ADMIN_USER_ID).single(),
    supabase.from("release_announcements").select("*").order("created_at", { ascending: false }),
  
DashboardPage function · typescript · L11-L55 (45 LOC)
app/(app)/dashboard/page.tsx
export default async function DashboardPage() {
  const cookieStore = cookies();
  const userName = cookieStore.get("user_name")?.value;
  if (!userName) redirect("/");

  const decodedUser = decodeURIComponent(userName);

  const supabase = createClient();

  // 先月1日を算出(ランキング用の範囲起点)
  const now = getNowJST();
  const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
  const lastMonthStr = `${lastMonth.getFullYear()}-${String(lastMonth.getMonth() + 1).padStart(2, "0")}-01`;

  // 並列でデータ取得
  const [myLogsRes, rankingLogsRes, usersRes] = await Promise.all([
    // 自分の全ログ(予定+実績)
    supabase
      .from("climbing_logs")
      .select("*")
      .eq("user", decodedUser)
      .order("date", { ascending: false }),
    // ランキング用: 全ユーザーの実績(先月1日以降)
    supabase
      .from("climbing_logs")
      .select("*")
      .eq("type", "実績")
      .gte("date", lastMonthStr)
      .order("date", { ascending: false }),
    // ユーザー一覧
    supabase.from("users").select("*").order("user_name"),
GraphPage function · typescript · L10-L54 (45 LOC)
app/(app)/graph/page.tsx
export default async function GraphPage() {
  const cookieStore = cookies();
  const userName = cookieStore.get("user_name")?.value;
  if (!userName) redirect("/");

  const decodedUser = decodeURIComponent(userName);

  const supabase = createClient();

  // 過去12ヶ月分を取得(クライアント側で期間フィルタ)
  const jstDate = (d: Date) => new Intl.DateTimeFormat('sv-SE', { timeZone: 'Asia/Tokyo' }).format(d);
  const twelveMonthsAgo = new Date();
  twelveMonthsAgo.setFullYear(twelveMonthsAgo.getFullYear() - 1);
  const cutoffStr = jstDate(twelveMonthsAgo);

  const todayStr = jstDate(new Date());

  const [logsRes, plansRes, usersRes] = await Promise.all([
    supabase
      .from("climbing_logs")
      .select("*")
      .eq("type", "実績")
      .gte("date", cutoffStr)
      .order("date", { ascending: false }),
    // 全ユーザーの直近の予定(次の予定を表示するため)
    supabase
      .from("climbing_logs")
      .select("*")
      .eq("type", "予定")
      .gte("date", todayStr)
      .order("date", { ascending: true }),
    supaba
GymsPage function · typescript · L10-L45 (36 LOC)
app/(app)/gyms/page.tsx
export default async function GymsPage() {
  const cookieStore = cookies();
  const userName = cookieStore.get("user_name")?.value;
  if (!userName) redirect("/");

  const decodedUser = decodeURIComponent(userName);
  const supabase = createClient();

  const [gymsRes, areasRes, allLogsRes, myLogsRes, schedulesRes, usersRes] = await Promise.all([
    supabase.from("gym_master").select("*").order("gym_name"),
    supabase.from("area_master").select("*").order("area_tag"),
    supabase.from("climbing_logs").select("*").order("date", { ascending: false }),
    supabase.from("climbing_logs").select("*").eq("user", decodedUser).order("date", { ascending: false }),
    supabase.from("set_schedules").select("*").order("start_date", { ascending: false }),
    supabase.from("users").select("*"),
  ]);

  const allLogs = (allLogsRes.data || []) as ClimbingLog[];
  const friendLogs = allLogs.filter((l) => l.user !== decodedUser);

  // ページビュー記録(非同期・fire-and-forget)
  addPageView(decodedUser, "gy
Powered by Repobility — scan your code at https://repobility.com
HomePage function · typescript · L10-L55 (46 LOC)
app/(app)/home/page.tsx
export default async function HomePage() {
  const cookieStore = cookies();
  const userName = cookieStore.get("user_name")?.value;
  if (!userName) redirect("/");

  const decodedUser = decodeURIComponent(userName);

  const supabase = createClient();

  // トップページは「今日以降3週間分の予定」と「今月の実績」のみ取得
  const todayStr = new Date().toLocaleDateString("ja-JP", { timeZone: "Asia/Tokyo" }).replace(/\//g, "-").replace(/(\d+)-(\d+)-(\d+)/, (_, y, m, d) => `${y}-${m.padStart(2,"0")}-${d.padStart(2,"0")}`);
  const cutoffDate = new Date();
  cutoffDate.setDate(cutoffDate.getDate() + 21);
  const cutoffStr = cutoffDate.toLocaleDateString("ja-JP", { timeZone: "Asia/Tokyo" }).replace(/\//g, "-").replace(/(\d+)-(\d+)-(\d+)/, (_, y, m, d) => `${y}-${m.padStart(2,"0")}-${d.padStart(2,"0")}`);
  // 先月1日(ランキングの先月タブ用)
  const nowDate = new Date();
  const lastMonth = new Date(nowDate.getFullYear(), nowDate.getMonth() - 1, 1);
  const monthStart = `${lastMonth.getFullYear()}-${String(lastMonth.getMonth() + 1).padS
PlanPage function · typescript · L14-L87 (74 LOC)
app/(app)/home/plan/page.tsx
export default async function PlanPage({ searchParams }: Props) {
  const cookieStore = cookies();
  const userName = cookieStore.get("user_name")?.value;
  if (!userName) redirect("/");
  const decodedUser = decodeURIComponent(userName);

  const supabase = createClient();

  const [actualsRes, plansRes, gymsRes, editLogRes, usersRes] = await Promise.all([
    // 直近30日の実績(よく行くジム用)
    supabase
      .from("climbing_logs")
      .select("*")
      .eq("user", decodedUser)
      .eq("type", "実績")
      .gte("date", getDateOffsetJST(-30))
      .order("date", { ascending: false }),
    // 自分の予定ログ全件(二重登録チェック用)
    supabase
      .from("climbing_logs")
      .select("*")
      .eq("user", decodedUser)
      .eq("type", "予定")
      .gte("date", getTodayJST()),
    supabase.from("gym_master").select("*").order("gym_name"),
    searchParams.editId
      ? supabase.from("climbing_logs").select("*").eq("id", searchParams.editId).single()
      : Promise.resolve({ data: null, error: null }),
   
AppLayout function · typescript · L7-L31 (25 LOC)
app/(app)/layout.tsx
export default function AppLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const pathname = usePathname();
  const isFullscreen = pathname === "/home/plan";

  return (
    <div className="min-h-screen bg-gray-50">
      {/* BottomNav分(4rem) + safe area bottom を下部余白として確保 */}
      <main
        className="max-w-lg mx-auto"
        style={isFullscreen
          ? {}
          : { paddingBottom: "calc(4rem + env(safe-area-inset-bottom))" }
        }
      >
        {children}
      </main>
      <BottomNav />
      <Toaster />
    </div>
  );
}
SchedulePage function · typescript · L9-L21 (13 LOC)
app/(app)/schedule/page.tsx
export default async function SchedulePage() {
  const cookieStore = cookies();
  const userName = cookieStore.get("user_name")?.value;
  if (!userName) redirect("/");

  const supabase = createClient();
  const { data } = await supabase
    .from("set_schedules")
    .select("*")
    .order("start_date", { ascending: false });

  return <ScheduleClient schedules={(data || []) as SetSchedule[]} />;
}
RootLayout function · typescript · L28-L56 (29 LOC)
app/layout.tsx
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ja">
      <head>
        <link rel="preconnect" href="https://fonts.googleapis.com" />
        <link
          rel="preconnect"
          href="https://fonts.gstatic.com"
          crossOrigin="anonymous"
        />
        <link
          href="https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@400;500;600;700&display=swap"
          rel="stylesheet"
        />
        <meta name="mobile-web-app-capable" content="yes" />
        <meta name="apple-mobile-web-app-capable" content="yes" />
        <meta
          name="apple-mobile-web-app-status-bar-style"
          content="black-translucent"
        />
      </head>
      <body className="min-h-screen bg-gray-50">{children}</body>
    </html>
  );
}
LoginPage function · typescript · L8-L27 (20 LOC)
app/page.tsx
export default async function LoginPage() {
  let users: User[] = [];
  try {
    const supabase = createClient();
    const { data } = await supabase
      .from("users")
      .select("*")
      .order("user_name");
    users = data || [];
  } catch (err) {
    console.error("Failed to fetch users:", err);
  }

  return (
    <>
      <LoginScreen users={users} />
      <Toaster />
    </>
  );
}
load_all_data function · python · L78-L82 (5 LOC)
app.py
def load_all_data():
    m = conn.read(worksheet="gym_master", ttl=10)
    s = conn.read(worksheet="schedules", ttl=10)
    l = conn.read(worksheet="climbing_logs", ttl=10)
    return m, s, l
getMonthRange function · typescript · L28-L42 (15 LOC)
components/admin/AdminClient.tsx
function getMonthRange() {
  const now = new Date();
  const months = [-1, 0, 1].map((offset) => {
    const d = new Date(now.getFullYear(), now.getMonth() + offset, 1);
    const yyyy = d.getFullYear();
    const mm = String(d.getMonth() + 1).padStart(2, "0");
    return {
      key: `${yyyy}-${mm}`,
      label: offset === -1 ? "先月" : offset === 0 ? "今月" : "来月",
      yyyy,
      mm,
    };
  });
  return months;
}
Want fix-PRs on findings? Install Repobility's GitHub App · github.com/apps/repobility-bot
formatJST function · typescript · L40-L48 (9 LOC)
components/admin/AnalyticsDashboard.tsx
function formatJST(iso: string): string {
  return new Date(iso).toLocaleString("ja-JP", {
    timeZone: "Asia/Tokyo",
    month: "2-digit",
    day: "2-digit",
    hour: "2-digit",
    minute: "2-digit",
  });
}
BarChart function · typescript · L51-L66 (16 LOC)
components/admin/AnalyticsDashboard.tsx
function BarChart({ data }: { data: { date: string; count: number }[] }) {
  const max = Math.max(...data.map((d) => d.count), 1);
  return (
    <div className="flex items-end gap-0.5 h-24 w-full">
      {data.map(({ date, count }) => (
        <div key={date} className="flex-1 flex flex-col items-center gap-0.5">
          <div
            className="w-full bg-orange-400 rounded-t-sm transition-all"
            style={{ height: `${(count / max) * 100}%`, minHeight: count > 0 ? 2 : 0 }}
          />
          <span className="text-[7px] text-gray-400 leading-none">{date.slice(5)}</span>
        </div>
      ))}
    </div>
  );
}
HBarChart function · typescript · L69-L95 (27 LOC)
components/admin/AnalyticsDashboard.tsx
function HBarChart({
  items,
  color = "bg-orange-400",
}: {
  items: { label: string; count: number }[];
  color?: string;
}) {
  const max = Math.max(...items.map((i) => i.count), 1);
  return (
    <div className="space-y-1.5">
      {items.map(({ label, count }) => (
        <div key={label} className="flex items-center gap-2">
          <span className="text-xs text-gray-600 w-36 flex-shrink-0 truncate">{label}</span>
          <div className="flex-1 bg-gray-100 rounded-full h-2 overflow-hidden">
            <div
              className={`${color} h-full rounded-full transition-all`}
              style={{ width: `${(count / max) * 100}%` }}
            />
          </div>
          <span className="text-xs font-semibold text-gray-700 w-8 text-right flex-shrink-0">
            {count}
          </span>
        </div>
      ))}
    </div>
  );
}
categorizeActions function · typescript · L98-L130 (33 LOC)
components/admin/AnalyticsDashboard.tsx
function categorizeActions(actionCounts: { action: string; count: number }[]) {
  const home = actionCounts.filter((a) =>
    ["record_tapped", "join_tapped", "plan_joined", "edit_tapped"].includes(a.action)
  );
  const plan = actionCounts.filter((a) =>
    [
      "plan_created",
      "log_created",
      "plan_updated",
      "plan_deleted",
      "gym_selected_search",
      "gym_selected_recent",
      "gym_selected_undecided",
    ].includes(a.action)
  );
  const gyms = actionCounts.filter((a) =>
    [
      "sort_distance",
      "sort_freshset",
      "sort_overdue",
      "gps_auto",
      "gps_button",
      "address_set",
      "nationwide_on",
      "nationwide_off",
      "load_more",
    ].includes(a.action)
  );
  const other = actionCounts.filter(
    (a) => ![...home, ...plan, ...gyms].find((x) => x.action === a.action)
  );
  return { home, plan, gyms, other };
}
SummaryCard function · typescript · L165-L183 (19 LOC)
components/admin/AnalyticsDashboard.tsx
function SummaryCard({
  label,
  value,
  sub,
  color = "text-gray-800",
}: {
  label: string;
  value: number;
  sub?: string;
  color?: string;
}) {
  return (
    <div className="bg-white rounded-2xl p-3 shadow-sm border border-gray-100">
      <p className="text-[10px] text-gray-400 mb-1 leading-tight">{label}</p>
      <p className={`text-2xl font-bold ${color}`}>{value}</p>
      {sub && <p className="text-[10px] text-gray-400 mt-0.5">{sub}</p>}
    </div>
  );
}
formatDateYMD function · typescript · L21-L25 (5 LOC)
components/dashboard/GymVisitHistory.tsx
function formatDateYMD(dateStr: string): string {
  const datePart = dateStr.slice(0, 10); // "YYYY-MM-DD" 部分のみ取得
  const [y, m, d] = datePart.split("-");
  return `${y}/${Number(m)}/${Number(d)}`;
}
StalenessBadge function · typescript · L27-L41 (15 LOC)
components/dashboard/GymVisitHistory.tsx
function StalenessBadge({ days }: { days: number }) {
  if (days < 30) return null;
  if (days < 60) {
    return (
      <span className="text-[10px] font-medium text-amber-600 bg-amber-50 px-1.5 py-0.5 rounded-full whitespace-nowrap">
        {days}日前
      </span>
    );
  }
  return (
    <span className="text-[10px] font-medium text-red-500 bg-red-50 px-1.5 py-0.5 rounded-full whitespace-nowrap">
      {days}日前
    </span>
  );
}
GymVisitHistory function · typescript · L43-L121 (79 LOC)
components/dashboard/GymVisitHistory.tsx
export function GymVisitHistory({ logs }: Props) {
  const [expanded, setExpanded] = useState(false);
  const today = getTodayJST();

  const actuals = logs.filter((l) => l.type === "実績");

  // ジム別集計
  const gymMap: Record<string, { count: number; lastDate: string }> = {};
  actuals.forEach((l) => {
    if (!gymMap[l.gym_name]) {
      gymMap[l.gym_name] = { count: 0, lastDate: l.date };
    }
    gymMap[l.gym_name].count++;
    if (l.date > gymMap[l.gym_name].lastDate) {
      gymMap[l.gym_name].lastDate = l.date;
    }
  });

  const gyms: GymSummary[] = Object.entries(gymMap)
    .map(([gymName, { count, lastDate }]) => ({
      gymName,
      totalCount: count,
      lastVisit: lastDate,
      daysSinceLastVisit: daysDiff(lastDate, today),
    }))
    .sort((a, b) => b.totalCount - a.totalCount);

  if (gyms.length === 0) {
    return (
      <div className="bg-white rounded-2xl p-4 shadow-sm border border-gray-100">
        <h3 className="text-sm font-semibold text-gray-700 mb-2 
Provenance: Repobility (https://repobility.com) — every score reproducible from /scan/
buildMonthData function · typescript · L18-L37 (20 LOC)
components/dashboard/MonthlyTrendChart.tsx
function buildMonthData(logs: ClimbingLog[]): MonthData[] {
  const now = getNowJST();
  const endYear = now.getFullYear();
  const endMonth = now.getMonth() + 1;
  const data: MonthData[] = [];

  for (let y = 2026; y <= endYear; y++) {
    const mStart = y === 2026 ? 1 : 1;
    const mEnd = y === endYear ? endMonth : 12;
    for (let m = mStart; m <= mEnd; m++) {
      const monthStr = `${y}-${String(m).padStart(2, "0")}`;
      const label = `${m}月`;
      const count = logs.filter(
        (l) => l.type === "実績" && l.date.startsWith(monthStr)
      ).length;
      data.push({ month: monthStr, label, count });
    }
  }
  return data;
}
getGymBreakdown function · typescript · L39-L48 (10 LOC)
components/dashboard/MonthlyTrendChart.tsx
function getGymBreakdown(logs: ClimbingLog[], monthStr: string) {
  const monthLogs = logs.filter(
    (l) => l.type === "実績" && l.date.startsWith(monthStr)
  );
  const gymCount: Record<string, number> = {};
  monthLogs.forEach((l) => {
    gymCount[l.gym_name] = (gymCount[l.gym_name] || 0) + 1;
  });
  return Object.entries(gymCount).sort(([, a], [, b]) => b - a);
}
MonthlyTrendChart function · typescript · L50-L138 (89 LOC)
components/dashboard/MonthlyTrendChart.tsx
export function MonthlyTrendChart({ logs }: Props) {
  const [selectedMonth, setSelectedMonth] = useState<string | null>(null);
  const data = buildMonthData(logs);
  const maxCount = Math.max(...data.map((d) => d.count), 1);

  const now = getNowJST();
  const currentMonthStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;

  const handleBarClick = (_: unknown, index: number) => {
    const month = data[index]?.month;
    if (!month) return;
    setSelectedMonth((prev) => (prev === month ? null : month));
  };

  const gymEntries = selectedMonth ? getGymBreakdown(logs, selectedMonth) : [];
  const selectedLabel = selectedMonth
    ? `${Number(selectedMonth.split("-")[1])}月`
    : "";

  return (
    <div className="bg-white rounded-2xl p-4 shadow-sm border border-gray-100">
      <div className="flex items-baseline justify-between mb-3">
        <h3 className="text-sm font-semibold text-gray-700">月別クライミング推移</h3>
        <span className="text-[10px] text-gray-4
MyPageClient function · typescript · L19-L51 (33 LOC)
components/dashboard/MyPageClient.tsx
export function MyPageClient({ initialLogs, rankingLogs, users, currentUser }: Props) {
  const [logs, setLogs] = useState<ClimbingLog[]>(initialLogs);

  const handleDeleted = useCallback(async () => {
    try {
      const res = await fetch("/api/logs");
      if (res.ok) setLogs(await res.json());
    } catch (e) {
      console.error(e);
    }
  }, []);

  return (
    <>
      <PageHeader title="マイページ" />
      <div className="px-4 py-4 space-y-5 page-enter">
        <ProfileHeader
          currentUser={currentUser}
          users={users}
          rankingLogs={rankingLogs}
        />
        <UpcomingPlans logs={logs} onDeleted={handleDeleted} />
        <MonthlyTrendChart logs={logs} />
        <GymVisitHistory logs={logs} />
        <MyRecordsAccordion
          logs={logs}
          currentUser={currentUser}
          onDeleted={handleDeleted}
        />
      </div>
    </>
  );
}
MyRecordsAccordion function · typescript · L18-L107 (90 LOC)
components/dashboard/MyRecordsAccordion.tsx
export function MyRecordsAccordion({ logs, currentUser, onDeleted }: Props) {
  const [isOpen, setIsOpen] = useState(false);
  const [deletingId, setDeletingId] = useState<string | null>(null);

  const actuals = logs
    .filter((l) => l.type === "実績" && l.user === currentUser)
    .sort((a, b) => b.date.localeCompare(a.date));

  const handleDelete = async (id: string) => {
    if (deletingId) return;
    setDeletingId(id);
    try {
      await deleteClimbingLog(id);
      toast({ title: "削除しました", variant: "success" as any });
      onDeleted();
    } catch {
      toast({ title: "削除に失敗しました", variant: "destructive" });
    } finally {
      setDeletingId(null);
    }
  };

  return (
    <div className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
      <button
        onClick={() => setIsOpen(!isOpen)}
        className="w-full flex items-center justify-between px-4 py-3 text-left"
      >
        <span className="text-sm font-semibold text-gray-700 flex 
getRank function · typescript · L13-L17 (5 LOC)
components/dashboard/ProfileHeader.tsx
function getRank(
  logs: ClimbingLog[],
  monthStr: string,
  userName: string
): { rank: number; count: number } | null {
RankBadge function · typescript · L38-L57 (20 LOC)
components/dashboard/ProfileHeader.tsx
function RankBadge({ label, rankInfo }: { label: string; rankInfo: { rank: number; count: number } | null }) {
  if (!rankInfo) {
    return (
      <span className="text-xs text-gray-400">
        {label} ランク外
      </span>
    );
  }
  const medal = RANK_MEDALS[rankInfo.rank];
  return (
    <span className="text-xs text-gray-600">
      {label}{" "}
      {medal ? (
        <span className="text-base">{medal}</span>
      ) : (
        <span className="font-bold">{rankInfo.rank}位</span>
      )}
    </span>
  );
}
ProfileHeader function · typescript · L59-L92 (34 LOC)
components/dashboard/ProfileHeader.tsx
export function ProfileHeader({ currentUser, users, rankingLogs }: Props) {
  const now = getNowJST();
  const thisMonthStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
  const lastMonthDate = new Date(now.getFullYear(), now.getMonth() - 1, 1);
  const lastMonthStr = `${lastMonthDate.getFullYear()}-${String(lastMonthDate.getMonth() + 1).padStart(2, "0")}`;

  const user = users.find((u) => u.user_name === currentUser);
  const thisMonthRank = getRank(rankingLogs, thisMonthStr, currentUser);
  const lastMonthRank = getRank(rankingLogs, lastMonthStr, currentUser);

  return (
    <div className="bg-white rounded-2xl p-4 shadow-sm border border-gray-100">
      <div className="flex items-center gap-3">
        <div
          className="w-12 h-12 rounded-full flex items-center justify-center text-white text-xl flex-shrink-0 shadow-sm"
          style={{ backgroundColor: user?.color || "#999" }}
        >
          {user?.icon || "?"}
        </div>
        <div 
Want this analysis on your repo? https://repobility.com/scan/
UpcomingPlans function · typescript · L20-L126 (107 LOC)
components/dashboard/UpcomingPlans.tsx
export function UpcomingPlans({ logs, onDeleted }: Props) {
  const router = useRouter();
  const [expanded, setExpanded] = useState(false);
  const [deletingId, setDeletingId] = useState<string | null>(null);
  const today = getTodayJST();

  const plans = logs
    .filter((l) => l.type === "予定" && l.date >= today)
    .sort((a, b) => a.date.localeCompare(b.date));

  const handleEdit = (logId: string) => {
    router.push(`/home/plan?editId=${logId}`);
  };

  const handleDelete = async (id: string) => {
    if (deletingId) return;
    setDeletingId(id);
    try {
      await deleteClimbingLog(id);
      toast({ title: "予定を削除しました", variant: "success" as any });
      onDeleted();
    } catch {
      toast({ title: "削除に失敗しました", variant: "destructive" });
    } finally {
      setDeletingId(null);
    }
  };

  if (plans.length === 0) {
    return (
      <div className="bg-white rounded-2xl p-4 shadow-sm border border-gray-100">
        <h3 className="text-sm font-semibold text-gray-7
formatPeriodLabel function · typescript · L53-L57 (5 LOC)
components/graph/GraphClient.tsx
function formatPeriodLabel(period: Period): string {
  const { start, end } = getPeriodRange(period);
  const fmt = (s: string) => { const [, m, d] = s.split("-"); return `${parseInt(m)}/${parseInt(d)}`; };
  return `${fmt(start)} 〜 ${fmt(end)}`;
}
fmtDate function · typescript · L59-L62 (4 LOC)
components/graph/GraphClient.tsx
function fmtDate(s: string): string {
  const [, m, d] = s.split("-");
  return `${parseInt(m)}/${parseInt(d)}`;
}
daysAgo function · typescript · L64-L72 (9 LOC)
components/graph/GraphClient.tsx
function daysAgo(dateStr: string): string {
  const jstToday = new Intl.DateTimeFormat('sv-SE', { timeZone: 'Asia/Tokyo' }).format(new Date());
  const diff = Math.floor((new Date(jstToday).getTime() - new Date(dateStr).getTime()) / 86400000);
  if (diff === 0) return "今日";
  if (diff === 1) return "昨日";
  if (diff < 7)  return `${diff}日前`;
  if (diff < 30) return `${Math.floor(diff / 7)}週間前`;
  return `${Math.floor(diff / 30)}ヶ月前`;
}
buildEdges function · typescript · L76-L105 (30 LOC)
components/graph/GraphClient.tsx
function buildEdges(logs: ClimbingLog[]): Edge[] {
  const groups = new Map<string, string[]>();
  for (const log of logs) {
    const key = `${log.date}|${log.gym_name}`;
    if (!groups.has(key)) groups.set(key, []);
    groups.get(key)!.push(log.user);
  }
  const edgeMap = new Map<string, Edge>();
  for (const [key, users] of Array.from(groups.entries())) {
    const [date, gymName] = key.split("|");
    const unique = Array.from(new Set(users));
    if (unique.length < 2) continue;
    for (let i = 0; i < unique.length; i++) {
      for (let j = i + 1; j < unique.length; j++) {
        const sorted = [unique[i], unique[j]].sort();
        const u1 = sorted[0] as string;
        const u2 = sorted[1] as string;
        const ek = `${u1}|||${u2}`;
        if (!edgeMap.has(ek)) edgeMap.set(ek, { user1: u1, user2: u2, count: 0, sessions: [] });
        const e = edgeMap.get(ek)!;
        e.count += 1;
        e.sessions.push({ date, gymName });
      }
    }
  }
  return Array.from(edg
GraphClient function · typescript · L109-L402 (294 LOC)
components/graph/GraphClient.tsx
export function GraphClient({ logs, plans, users, currentUser }: Props) {
  const [period, setPeriod] = useState<Period>("thisMonth");
  const [selectedNode, setSelectedNode] = useState<string | null>(null);
  const [selectedEdge, setSelectedEdge] = useState<Edge | null>(null);

  const filteredLogs = useMemo(() => {
    const { start, end } = getPeriodRange(period);
    return logs.filter((l) => {
      const d = l.date.slice(0, 10);
      return d >= start && d <= end;
    });
  }, [logs, period]);

  const edges     = useMemo(() => buildEdges(filteredLogs), [filteredLogs]);
  const allEdges  = useMemo(() => buildEdges(logs), [logs]);

  const userMap = new Map(users.map((u) => [u.user_name, u]));

  const connectedUsers = new Set<string>();
  filteredLogs.forEach((l) => connectedUsers.add(l.user));
  const nodeIds = Array.from(connectedUsers).filter((id) => userMap.has(id));
  const n = nodeIds.length;

  const positions = useMemo(() => {
    const pos: Record<string, { x: number; y
MiniInfo function · typescript · L406-L415 (10 LOC)
components/graph/GraphClient.tsx
function MiniInfo({ label, value, empty }: { label: string; value: string; empty?: boolean }) {
  return (
    <div>
      <p className="text-[9px] text-gray-400 mb-0.5">{label}</p>
      <p className={`text-[11px] font-semibold leading-snug ${empty ? "text-gray-300" : "text-gray-700"}`}>
        {value}
      </p>
    </div>
  );
}
EdgeSheet function · typescript · L419-L480 (62 LOC)
components/graph/GraphClient.tsx
function EdgeSheet({ edge, users, onClose }: { edge: Edge; users: User[]; onClose: () => void }) {
  const userMap = new Map(users.map((u) => [u.user_name, u]));
  const u1 = userMap.get(edge.user1);
  const u2 = userMap.get(edge.user2);

  const byMonth = new Map<string, Session[]>();
  for (const s of edge.sessions) {
    const m = s.date.slice(0, 7);
    if (!byMonth.has(m)) byMonth.set(m, []);
    byMonth.get(m)!.push(s);
  }
  const months = Array.from(byMonth.entries()).sort((a, b) => b[0].localeCompare(a[0]));

  return (
    <>
      <div className="fixed inset-0 z-[60] bg-black/40" onClick={onClose} />
      <div className="fixed left-0 right-0 bottom-0 z-[60] bg-white rounded-t-2xl shadow-2xl flex flex-col"
        style={{ maxHeight: "65vh", paddingBottom: "calc(env(safe-area-inset-bottom) + 64px)" }}>
        <div className="flex justify-center pt-3 pb-1 flex-shrink-0">
          <div className="w-10 h-1 rounded-full bg-gray-200" />
        </div>
        <div className="fl
Powered by Repobility — scan your code at https://repobility.com
GymCard function · typescript · L23-L173 (151 LOC)
components/gyms/GymCard.tsx
export function GymCard({
  gym,
  distanceKm,
  latestSchedule,
  lastVisit,
  setAge,
  lastVisitDays,
  friendLogsOnDate,
  users,
  isSub = false,
}: Props) {

  // バッジ計算
  const badges: Badge[] = [];

  if (setAge != null) {
    if (setAge <= 7)       badges.push({ label: "🔥 新セット",  cls: "bg-orange-100 text-orange-600" });
    else if (setAge <= 14) badges.push({ label: "✨ 準新セット", cls: "bg-yellow-100 text-yellow-700" });
  }

  if (lastVisit == null) {
    badges.push({ label: "🆕 未訪問", cls: "bg-blue-50 text-blue-500" });
  } else if (lastVisitDays != null && lastVisitDays >= 30) {
    badges.push({ label: "⌛ ごぶさた", cls: "bg-red-50 text-red-500" });
  }

  // 最終登攀日(先頭10文字=YYYY-MM-DD のみ使う)
  const lastVisitDate = lastVisit ? lastVisit.slice(0, 10) : null;
  const lastVisitFull = lastVisitDate ? lastVisitDate.replace(/-/g, "/") : null;

  // 時間帯アイコンパス(/images/hiru.png 等)
  const getTimeIcon = (timeSlot: string | null): string | null => {
    if (!timeSlot) return null;
    return TIM
GymsClient function · typescript · L31-L374 (344 LOC)
components/gyms/GymsClient.tsx
export function GymsClient({
  gyms, areas, myLogs, friendLogs, setSchedules, users, currentUser,
}: Props) {
  const [targetDate, setTargetDate] = useState(getTodayJST());
  const [origin, setOrigin] = useState<Origin>(null);
  const [originInput, setOriginInput] = useState("現在地");
  const [geocodeError, setGeocodeError] = useState("");
  const [gpsLoading, setGpsLoading] = useState(false);
  const [showAll, setShowAll] = useState(false);
  const [sortTab, setSortTab] = useState<SortTab>("distance");
  const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);

  // 起動時に現在地を自動取得
  useEffect(() => {
    if (!navigator.geolocation) return;
    setGpsLoading(true);
    navigator.geolocation.getCurrentPosition(
      (pos) => {
        setOrigin({ lat: pos.coords.latitude, lng: pos.coords.longitude });
        trackAction(currentUser, "gyms", "gps_auto");
        setGpsLoading(false);
      },
      () => setGpsLoading(false),
      { timeout: 10000 }
    );
  }, [currentUser]);

  // タ
AnnouncementHistory function · typescript · L18-L59 (42 LOC)
components/home/AnnouncementBanner.tsx
function AnnouncementHistory() {
  const [items, setItems] = useState<Announcement[] | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch("/api/announcements?all=true")
      .then((r) => r.json())
      .then(setItems)
      .finally(() => setLoading(false));
  }, []);

  const today = new Date().toISOString().slice(0, 10);

  if (loading) {
    return <p className="text-xs text-gray-400 text-center py-8">読み込み中…</p>;
  }
  if (!items || items.length === 0) {
    return <p className="text-xs text-gray-400 text-center py-8">お知らせはまだありません</p>;
  }

  return (
    <div className="overflow-y-auto max-h-[55vh] space-y-3 pr-1 -mr-1">
      {items.map((a) => {
        const expired = a.display_until < today;
        return (
          <div
            key={a.id}
            className={`rounded-xl p-3 border ${expired ? "bg-gray-50 border-gray-100" : "bg-orange-50 border-orange-100"}`}
          >
            <p className={`text-sm leading-relaxed break-
page 1 / 3next ›