Function bodies 116 total
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("clWant 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, createAdminPage 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 }),
supabaGymsPage 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, "gyPowered 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).padSPlanPage 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, lgetMonthRange 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-4MyPageClient 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-7formatPeriodLabel 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(edgGraphClient 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; yMiniInfo 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="flPowered 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 TIMGymsClient 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 ›