気象データ画面を追加(月別サマリー・直近14日)
- /weather ページ: 月別集計テーブル・年間サマリーカード・直近14日日次記録 - Navbar に「気象」リンク追加 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
310
frontend/src/app/weather/page.tsx
Normal file
310
frontend/src/app/weather/page.tsx
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import Navbar from '@/components/Navbar';
|
||||||
|
import { Cloud, Loader2, Sun, Droplets, Wind, Thermometer } from 'lucide-react';
|
||||||
|
|
||||||
|
interface MonthlyRecord {
|
||||||
|
month: number;
|
||||||
|
temp_mean_avg: number | null;
|
||||||
|
temp_max_avg: number | null;
|
||||||
|
temp_min_avg: number | null;
|
||||||
|
precip_total: number | null;
|
||||||
|
sunshine_total: number | null;
|
||||||
|
wind_max: number | null;
|
||||||
|
hot_days: number;
|
||||||
|
cold_days: number;
|
||||||
|
rainy_days: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AnnualSummary {
|
||||||
|
temp_mean_avg: number | null;
|
||||||
|
precip_total: number | null;
|
||||||
|
sunshine_total: number | null;
|
||||||
|
hot_days: number;
|
||||||
|
cold_days: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SummaryResponse {
|
||||||
|
year: number;
|
||||||
|
monthly: MonthlyRecord[];
|
||||||
|
annual: AnnualSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DailyRecord {
|
||||||
|
date: string;
|
||||||
|
temp_mean: number | null;
|
||||||
|
temp_max: number | null;
|
||||||
|
temp_min: number | null;
|
||||||
|
sunshine_h: number | null;
|
||||||
|
precip_mm: number | null;
|
||||||
|
wind_max: number | null;
|
||||||
|
pressure_min: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MONTHS = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'];
|
||||||
|
|
||||||
|
function fmt(val: number | null, digits = 1): string {
|
||||||
|
return val == null ? '—' : val.toFixed(digits);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WeatherPage() {
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const [year, setYear] = useState(currentYear);
|
||||||
|
const [tab, setTab] = useState<'summary' | 'recent'>('summary');
|
||||||
|
|
||||||
|
const [summary, setSummary] = useState<SummaryResponse | null>(null);
|
||||||
|
const [summaryLoading, setSummaryLoading] = useState(true);
|
||||||
|
|
||||||
|
const [recentRecords, setRecentRecords] = useState<DailyRecord[]>([]);
|
||||||
|
const [recentLoading, setRecentLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSummaryLoading(true);
|
||||||
|
api.get(`/weather/summary/?year=${year}`)
|
||||||
|
.then((res) => setSummary(res.data))
|
||||||
|
.catch((err) => console.error(err))
|
||||||
|
.finally(() => setSummaryLoading(false));
|
||||||
|
}, [year]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setRecentLoading(true);
|
||||||
|
const end = new Date();
|
||||||
|
end.setDate(end.getDate() - 1);
|
||||||
|
const start = new Date(end);
|
||||||
|
start.setDate(start.getDate() - 13);
|
||||||
|
const fmt2 = (d: Date) => d.toISOString().slice(0, 10);
|
||||||
|
api.get(`/weather/records/?start=${fmt2(start)}&end=${fmt2(end)}`)
|
||||||
|
.then((res) => {
|
||||||
|
const data: DailyRecord[] = res.data;
|
||||||
|
setRecentRecords([...data].reverse());
|
||||||
|
})
|
||||||
|
.catch((err) => console.error(err))
|
||||||
|
.finally(() => setRecentLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const years: number[] = [];
|
||||||
|
for (let y = currentYear; y >= 2016; y--) years.push(y);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<Navbar />
|
||||||
|
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
|
||||||
|
{/* ヘッダー */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Cloud className="h-6 w-6 text-sky-500" />
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">気象データ</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<select
|
||||||
|
value={year}
|
||||||
|
onChange={(e) => setYear(Number(e.target.value))}
|
||||||
|
className="px-3 py-1.5 border border-gray-300 rounded-md text-sm"
|
||||||
|
>
|
||||||
|
{years.map((y) => (
|
||||||
|
<option key={y} value={y}>{y}年</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* タブ */}
|
||||||
|
<div className="flex border-b border-gray-200 mb-6">
|
||||||
|
<button
|
||||||
|
onClick={() => setTab('summary')}
|
||||||
|
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||||
|
tab === 'summary'
|
||||||
|
? 'border-sky-500 text-sky-600'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
月別サマリー
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setTab('recent')}
|
||||||
|
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||||
|
tab === 'recent'
|
||||||
|
? 'border-sky-500 text-sky-600'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
直近14日
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ===== 月別サマリー ===== */}
|
||||||
|
{tab === 'summary' && (
|
||||||
|
summaryLoading ? (
|
||||||
|
<div className="flex justify-center py-20">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-gray-400" />
|
||||||
|
</div>
|
||||||
|
) : summary ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 年間サマリーカード */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
<div className="bg-white rounded-lg shadow p-4">
|
||||||
|
<div className="flex items-center gap-1.5 mb-1">
|
||||||
|
<Thermometer className="h-4 w-4 text-orange-400" />
|
||||||
|
<p className="text-xs text-gray-500">年間平均気温</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">
|
||||||
|
{fmt(summary.annual.temp_mean_avg)}<span className="text-sm font-normal text-gray-500 ml-1">℃</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-lg shadow p-4">
|
||||||
|
<div className="flex items-center gap-1.5 mb-1">
|
||||||
|
<Droplets className="h-4 w-4 text-blue-400" />
|
||||||
|
<p className="text-xs text-gray-500">年間降水量</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">
|
||||||
|
{fmt(summary.annual.precip_total, 0)}<span className="text-sm font-normal text-gray-500 ml-1">mm</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-lg shadow p-4">
|
||||||
|
<div className="flex items-center gap-1.5 mb-1">
|
||||||
|
<Sun className="h-4 w-4 text-yellow-400" />
|
||||||
|
<p className="text-xs text-gray-500">年間日照時間</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">
|
||||||
|
{fmt(summary.annual.sunshine_total, 0)}<span className="text-sm font-normal text-gray-500 ml-1">h</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-lg shadow p-4">
|
||||||
|
<div className="flex items-center gap-1.5 mb-1">
|
||||||
|
<Wind className="h-4 w-4 text-gray-400" />
|
||||||
|
<p className="text-xs text-gray-500">猛暑日 / 冬日</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">
|
||||||
|
{summary.annual.hot_days}<span className="text-sm font-normal text-red-400 ml-1">猛暑</span>
|
||||||
|
<span className="text-sm font-normal text-gray-400 mx-1">/</span>
|
||||||
|
{summary.annual.cold_days}<span className="text-sm font-normal text-blue-400 ml-1">冬</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 月別テーブル */}
|
||||||
|
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50 border-b border-gray-200">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2.5 text-left text-xs font-semibold text-gray-600">月</th>
|
||||||
|
<th className="px-3 py-2.5 text-right text-xs font-semibold text-gray-600">平均気温</th>
|
||||||
|
<th className="px-3 py-2.5 text-right text-xs font-semibold text-gray-600">最高(avg)</th>
|
||||||
|
<th className="px-3 py-2.5 text-right text-xs font-semibold text-gray-600">最低(avg)</th>
|
||||||
|
<th className="px-3 py-2.5 text-right text-xs font-semibold text-gray-600">日照h</th>
|
||||||
|
<th className="px-3 py-2.5 text-right text-xs font-semibold text-gray-600">降水mm</th>
|
||||||
|
<th className="px-3 py-2.5 text-right text-xs font-semibold text-gray-600 whitespace-nowrap">猛暑日</th>
|
||||||
|
<th className="px-3 py-2.5 text-right text-xs font-semibold text-gray-600 whitespace-nowrap">冬日</th>
|
||||||
|
<th className="px-3 py-2.5 text-right text-xs font-semibold text-gray-600 whitespace-nowrap">雨天日</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{summary.monthly.map((m) => (
|
||||||
|
<tr key={m.month} className="hover:bg-gray-50">
|
||||||
|
<td className="px-3 py-2 font-medium text-gray-900">{MONTHS[m.month - 1]}</td>
|
||||||
|
<td className="px-3 py-2 text-right text-gray-700">
|
||||||
|
{m.temp_mean_avg == null ? '—' : (
|
||||||
|
<span className={m.temp_mean_avg >= 25 ? 'text-orange-500 font-medium' : m.temp_mean_avg < 5 ? 'text-blue-500 font-medium' : ''}>
|
||||||
|
{fmt(m.temp_mean_avg)}℃
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right text-gray-500">{fmt(m.temp_max_avg)}℃</td>
|
||||||
|
<td className="px-3 py-2 text-right text-gray-500">{fmt(m.temp_min_avg)}℃</td>
|
||||||
|
<td className="px-3 py-2 text-right text-gray-700">{fmt(m.sunshine_total, 0)}</td>
|
||||||
|
<td className="px-3 py-2 text-right text-gray-700">{fmt(m.precip_total, 0)}</td>
|
||||||
|
<td className="px-3 py-2 text-right">
|
||||||
|
{m.hot_days > 0 ? <span className="text-red-500 font-medium">{m.hot_days}</span> : <span className="text-gray-300">0</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right">
|
||||||
|
{m.cold_days > 0 ? <span className="text-blue-500 font-medium">{m.cold_days}</span> : <span className="text-gray-300">0</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right text-gray-600">{m.rainy_days}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-2 bg-gray-50 border-t border-gray-200 text-xs text-gray-400">
|
||||||
|
猛暑日: 最高気温 ≥ 35℃ 冬日: 最低気温 < 0℃ 雨天日: 降水量 ≥ 1mm
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="py-20 text-center text-gray-500 text-sm">データの取得に失敗しました</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ===== 直近14日 ===== */}
|
||||||
|
{tab === 'recent' && (
|
||||||
|
recentLoading ? (
|
||||||
|
<div className="flex justify-center py-20">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-gray-400" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50 border-b border-gray-200">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2.5 text-left text-xs font-semibold text-gray-600">日付</th>
|
||||||
|
<th className="px-3 py-2.5 text-right text-xs font-semibold text-gray-600">平均気温</th>
|
||||||
|
<th className="px-3 py-2.5 text-right text-xs font-semibold text-gray-600">最高</th>
|
||||||
|
<th className="px-3 py-2.5 text-right text-xs font-semibold text-gray-600">最低</th>
|
||||||
|
<th className="px-3 py-2.5 text-right text-xs font-semibold text-gray-600">日照h</th>
|
||||||
|
<th className="px-3 py-2.5 text-right text-xs font-semibold text-gray-600">降水mm</th>
|
||||||
|
<th className="px-3 py-2.5 text-right text-xs font-semibold text-gray-600">最大風速</th>
|
||||||
|
<th className="px-3 py-2.5 text-right text-xs font-semibold text-gray-600">最低気圧</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{recentRecords.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} className="px-3 py-8 text-center text-gray-400">データがありません</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
recentRecords.map((r) => (
|
||||||
|
<tr key={r.date} className="hover:bg-gray-50">
|
||||||
|
<td className="px-3 py-2 font-medium text-gray-900 whitespace-nowrap">{r.date}</td>
|
||||||
|
<td className="px-3 py-2 text-right text-gray-700">{fmt(r.temp_mean)}℃</td>
|
||||||
|
<td className="px-3 py-2 text-right">
|
||||||
|
<span className={r.temp_max != null && r.temp_max >= 35 ? 'text-red-500 font-medium' : 'text-gray-500'}>
|
||||||
|
{fmt(r.temp_max)}℃
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right">
|
||||||
|
<span className={r.temp_min != null && r.temp_min < 0 ? 'text-blue-500 font-medium' : 'text-gray-500'}>
|
||||||
|
{fmt(r.temp_min)}℃
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right text-gray-700">{fmt(r.sunshine_h)}</td>
|
||||||
|
<td className="px-3 py-2 text-right">
|
||||||
|
<span className={r.precip_mm != null && r.precip_mm >= 10 ? 'text-blue-600 font-medium' : 'text-gray-700'}>
|
||||||
|
{fmt(r.precip_mm)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right text-gray-500">{fmt(r.wind_max)}</td>
|
||||||
|
<td className="px-3 py-2 text-right text-gray-500">{fmt(r.pressure_min, 0)}</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{recentRecords.length > 0 && (
|
||||||
|
<div className="px-3 py-2 bg-gray-50 border-t border-gray-200 text-xs text-gray-400">
|
||||||
|
最新: {recentRecords[0].date} 全{recentRecords.length}件表示
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useRouter, usePathname } from 'next/navigation';
|
import { useRouter, usePathname } from 'next/navigation';
|
||||||
import { LogOut, Wheat, MapPin, FileText, Upload, LayoutDashboard, Mail, History, Shield, KeyRound } from 'lucide-react';
|
import { LogOut, Wheat, MapPin, FileText, Upload, LayoutDashboard, Mail, History, Shield, KeyRound, Cloud } from 'lucide-react';
|
||||||
import { logout } from '@/lib/api';
|
import { logout } from '@/lib/api';
|
||||||
|
|
||||||
export default function Navbar() {
|
export default function Navbar() {
|
||||||
@@ -100,6 +100,17 @@ export default function Navbar() {
|
|||||||
<Shield className="h-4 w-4 mr-2" />
|
<Shield className="h-4 w-4 mr-2" />
|
||||||
メールルール
|
メールルール
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/weather')}
|
||||||
|
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
|
||||||
|
isActive('/weather')
|
||||||
|
? 'text-green-700 bg-green-50'
|
||||||
|
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Cloud className="h-4 w-4 mr-2" />
|
||||||
|
気象
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-1">
|
<div className="flex items-center space-x-1">
|
||||||
|
|||||||
Reference in New Issue
Block a user