Rewrite as Next.js app with Livonia, MI location
This commit is contained in:
36
.gitignore
vendored
Normal file
36
.gitignore
vendored
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
# data (server-side storage)
|
||||||
|
data/
|
||||||
60
README.md
Normal file
60
README.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# 🌤️ Weather or Not
|
||||||
|
|
||||||
|
A Next.js web app that helps you decide whether to ride your motorcycle or mow the lawn based on weather conditions.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Real-time weather** from Open-Meteo API (free, no API key needed)
|
||||||
|
- **Motorcycle ride checker** — evaluates temp, precipitation, wind, humidity, road conditions
|
||||||
|
- **Lawn mowing checker** — weather + tracks when you last mowed (cooldown system)
|
||||||
|
- **Record activities** — mark when you've done something to avoid daily suggestions
|
||||||
|
- **Beautiful dark UI** with status indicators and detailed reasoning
|
||||||
|
- **Client-side config** — set your location and thresholds in localStorage
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Run development server
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Open http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build & Deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build for production
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Start production server
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Settings are stored in your browser's localStorage. Default location is Livonia, Michigan.
|
||||||
|
|
||||||
|
To change location or thresholds, edit `src/lib/storage.ts` or add a settings page.
|
||||||
|
|
||||||
|
## Adding New Activities
|
||||||
|
|
||||||
|
1. Create a new evaluator function in `src/lib/activities.ts`
|
||||||
|
2. Add it to the `/api/activities/route.ts` endpoint
|
||||||
|
3. Create a new component in `src/app/components/`
|
||||||
|
4. Add it to `src/app/page.tsx`
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Next.js 14** — App Router, API routes
|
||||||
|
- **TypeScript** — Full type safety
|
||||||
|
- **Tailwind CSS** — Styling
|
||||||
|
- **Open-Meteo** — Free weather data
|
||||||
|
- **localStorage** — Client-side config & history
|
||||||
|
- **Server filesystem** — Server-side activity history
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
43
config.yaml
Normal file
43
config.yaml
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# Weather or Not - Configuration
|
||||||
|
# Location for weather data (Open-Meteo)
|
||||||
|
location:
|
||||||
|
latitude: 40.7128 # Change to your latitude
|
||||||
|
longitude: -74.0060 # Change to your longitude
|
||||||
|
label: "Home" # Display name
|
||||||
|
|
||||||
|
# Activity thresholds
|
||||||
|
activities:
|
||||||
|
motorcycle_ride:
|
||||||
|
enabled: true
|
||||||
|
min_temp_c: 10
|
||||||
|
max_temp_c: 35
|
||||||
|
max_precipitation_mm: 0 # No rain allowed
|
||||||
|
max_wind_speed_kmh: 30
|
||||||
|
max_humidity_pct: 90
|
||||||
|
no_ice: true # No freezing conditions
|
||||||
|
no_wet_roads: true # No rain or snow in past 2 hours
|
||||||
|
notes: "Requires dry roads, no precipitation, comfortable temperature"
|
||||||
|
|
||||||
|
mow_lawn:
|
||||||
|
enabled: true
|
||||||
|
# Cooldown: minimum days between mowing suggestions
|
||||||
|
cooldown_days: 3
|
||||||
|
max_precipitation_today_mm: 0 # No rain expected today
|
||||||
|
max_precipitation_past_24h_mm: 5 # Ground should be mostly dry
|
||||||
|
max_wind_speed_kmh: 25
|
||||||
|
min_temp_c: 15
|
||||||
|
notes: "Checks that you haven't mowed too recently and ground is dry"
|
||||||
|
|
||||||
|
# Notification settings
|
||||||
|
notifications:
|
||||||
|
mattermost:
|
||||||
|
enabled: true
|
||||||
|
webhook_url: "" # Your Mattermost incoming webhook URL
|
||||||
|
channel: "#general" # Or your preferred channel
|
||||||
|
# For Mattermost home channel delivery via Hermes, use the hermes notify endpoint
|
||||||
|
hermes_notify: true # Set to true to send via Hermes Mattermost integration
|
||||||
|
|
||||||
|
# Output format
|
||||||
|
output:
|
||||||
|
verbose: true # Show detailed reasoning
|
||||||
|
colorize: true # Use colors in terminal output (requires colorama)
|
||||||
13
next.config.js
Normal file
13
next.config.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: 'openweathermap.org',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = nextConfig;
|
||||||
26
package.json
Normal file
26
package.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "weather-or-not",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"next": "^14.2.0",
|
||||||
|
"react": "^18.3.0",
|
||||||
|
"react-dom": "^18.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.14.0",
|
||||||
|
"@types/react": "^18.3.0",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"autoprefixer": "^10.4.0",
|
||||||
|
"postcss": "^8.4.0",
|
||||||
|
"tailwindcss": "^3.4.0",
|
||||||
|
"typescript": "^5.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
59
src/app/api/activities/record/route.ts
Normal file
59
src/app/api/activities/record/route.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { activity } = body;
|
||||||
|
|
||||||
|
if (!activity || !['motorcycle_ride', 'mow_lawn'].includes(activity)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Invalid activity name' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store in a JSON file for the API route to read
|
||||||
|
const fs = await import('fs');
|
||||||
|
const path = await import('path');
|
||||||
|
const filePath = path.join(process.cwd(), 'data', 'history.json');
|
||||||
|
|
||||||
|
// Create data directory if it doesn't exist
|
||||||
|
const dataDir = path.join(process.cwd(), 'data');
|
||||||
|
if (!fs.existsSync(dataDir)) {
|
||||||
|
fs.mkdirSync(dataDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
let history: Array<{ timestamp: string; activity: string; performed: boolean }> = [];
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
try {
|
||||||
|
history = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||||
|
} catch {
|
||||||
|
history = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
history.push({
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
activity,
|
||||||
|
performed: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep only last 100 entries
|
||||||
|
if (history.length > 100) {
|
||||||
|
history = history.slice(-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(filePath, JSON.stringify(history, null, 2));
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: `Recorded: ${activity}`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Record activity error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Failed to record activity' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/app/api/activities/route.ts
Normal file
43
src/app/api/activities/route.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { fetchWeather } from '@/lib/weather';
|
||||||
|
import { evaluateMotorcycle, evaluateLawn } from '@/lib/activities';
|
||||||
|
import { loadConfig } from '@/lib/storage';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const lat = parseFloat(searchParams.get('lat') || '42.3665');
|
||||||
|
const lon = parseFloat(searchParams.get('lon') || '-83.3957');
|
||||||
|
|
||||||
|
const weather = await fetchWeather(lat, lon);
|
||||||
|
const config = loadConfig();
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
if (config.activities.motorcycle_ride.enabled) {
|
||||||
|
results.push(evaluateMotorcycle(weather, config.activities.motorcycle_ride));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.activities.mow_lawn.enabled) {
|
||||||
|
results.push(evaluateLawn(weather, config.activities.mow_lawn));
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: results,
|
||||||
|
weather: {
|
||||||
|
temp: weather.current.temperature_2m,
|
||||||
|
description: weather.current.weather_code,
|
||||||
|
wind: weather.current.wind_speed_10m,
|
||||||
|
humidity: weather.current.relative_humidity_2m,
|
||||||
|
location: `${weather.latitude}, ${weather.longitude}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Activities API error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Failed to evaluate activities' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/app/api/weather/route.ts
Normal file
23
src/app/api/weather/route.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { fetchWeather } from '@/lib/weather';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const lat = parseFloat(searchParams.get('lat') || '42.3665');
|
||||||
|
const lon = parseFloat(searchParams.get('lon') || '-83.3957');
|
||||||
|
|
||||||
|
const weather = await fetchWeather(lat, lon);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: weather,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Weather API error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Failed to fetch weather data' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
115
src/app/components/ActivityCard.tsx
Normal file
115
src/app/components/ActivityCard.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { ActivityResult } from '@/types';
|
||||||
|
|
||||||
|
interface ActivityCardProps {
|
||||||
|
result: ActivityResult;
|
||||||
|
onRecord: (activity: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ActivityCard({ result, onRecord }: ActivityCardProps) {
|
||||||
|
const [showDetails, setShowDetails] = useState(false);
|
||||||
|
const [recording, setRecording] = useState(false);
|
||||||
|
|
||||||
|
const isGo = result.verdict === 'GO';
|
||||||
|
const isCaution = result.verdict === 'CAUTION';
|
||||||
|
|
||||||
|
const getStatusColor = () => {
|
||||||
|
if (isGo) return 'border-emerald-500/50 bg-emerald-500/10';
|
||||||
|
if (isCaution) return 'border-amber-500/50 bg-amber-500/10';
|
||||||
|
return 'border-red-500/50 bg-red-500/10';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadge = () => {
|
||||||
|
if (isGo) return (
|
||||||
|
<span className="inline-flex items-center gap-1 px-3 py-1 rounded-full bg-emerald-500/20 text-emerald-400 text-sm font-semibold animate-pulse-green">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-emerald-400"></span>
|
||||||
|
GO
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
if (isCaution) return (
|
||||||
|
<span className="inline-flex items-center gap-1 px-3 py-1 rounded-full bg-amber-500/20 text-amber-400 text-sm font-semibold">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-amber-400"></span>
|
||||||
|
CAUTION
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 px-3 py-1 rounded-full bg-red-500/20 text-red-400 text-sm font-semibold animate-pulse-red">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-red-400"></span>
|
||||||
|
NO-GO
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRecord = async () => {
|
||||||
|
setRecording(true);
|
||||||
|
try {
|
||||||
|
await onRecord(result.name.toLowerCase().includes('motorcycle') ? 'motorcycle_ride' : 'mow_lawn');
|
||||||
|
} finally {
|
||||||
|
setRecording(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`rounded-2xl border-2 ${getStatusColor()} p-6 animate-fade-in`}>
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-4xl">{result.icon}</span>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-bold text-white">{result.name}</h3>
|
||||||
|
<p className="text-slate-400 text-sm">{result.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{getStatusBadge()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rule results */}
|
||||||
|
<div className="space-y-2 mb-4">
|
||||||
|
{result.rules.map((rule, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className={`flex items-center gap-2 text-sm p-2 rounded-lg ${
|
||||||
|
rule.passed ? 'bg-emerald-500/5' : 'bg-red-500/5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={rule.passed ? 'text-emerald-400' : 'text-red-400'}>
|
||||||
|
{rule.passed ? '✓' : '✗'}
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-300">{rule.name}</span>
|
||||||
|
<span className="text-slate-500 ml-auto text-xs">{rule.message}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Toggle details */}
|
||||||
|
{result.reasoning.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDetails(!showDetails)}
|
||||||
|
className="text-sm text-blue-400 hover:text-blue-300 mb-3"
|
||||||
|
>
|
||||||
|
{showDetails ? 'Hide details ▲' : 'Show reasoning ▼'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showDetails && (
|
||||||
|
<div className="bg-slate-800/50 rounded-lg p-3 mb-4">
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
<span className="text-slate-300 font-medium">Why?</span>{' '}
|
||||||
|
{result.reasoning.join('. ')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Record button */}
|
||||||
|
<button
|
||||||
|
onClick={handleRecord}
|
||||||
|
disabled={recording}
|
||||||
|
className="w-full mt-2 px-4 py-2 rounded-lg bg-slate-700 hover:bg-slate-600
|
||||||
|
text-slate-300 hover:text-white transition-colors text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{recording ? 'Recording...' : `✓ Record ${result.name} as done`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
148
src/app/components/ActivityCheck.tsx
Normal file
148
src/app/components/ActivityCheck.tsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import ActivityCard from './ActivityCard';
|
||||||
|
import WeatherDisplay from './WeatherDisplay';
|
||||||
|
import { ActivityResult } from '@/types';
|
||||||
|
|
||||||
|
export default function ActivityCheck() {
|
||||||
|
const [results, setResults] = useState<ActivityResult[]>([]);
|
||||||
|
const [weather, setWeather] = useState<any>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [lastUpdated, setLastUpdated] = useState<string>('');
|
||||||
|
const [config, setConfig] = useState<any>(null);
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const storedConfig = localStorage.getItem('won_config');
|
||||||
|
const parsedConfig = storedConfig ? JSON.parse(storedConfig) : null;
|
||||||
|
setConfig(parsedConfig);
|
||||||
|
|
||||||
|
const lat = parsedConfig?.latitude ?? 42.3665;
|
||||||
|
const lon = parsedConfig?.longitude ?? -83.3957;
|
||||||
|
|
||||||
|
const [weatherRes, activitiesRes] = await Promise.all([
|
||||||
|
fetch(`/api/weather?lat=${lat}&lon=${lon}`),
|
||||||
|
fetch(`/api/activities?lat=${lat}&lon=${lon}`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!weatherRes.ok || !activitiesRes.ok) {
|
||||||
|
throw new Error('Failed to fetch data');
|
||||||
|
}
|
||||||
|
|
||||||
|
const weatherData = await weatherRes.json();
|
||||||
|
const activitiesData = await activitiesRes.json();
|
||||||
|
|
||||||
|
if (weatherData.success && activitiesData.success) {
|
||||||
|
setWeather(activitiesData.weather);
|
||||||
|
setResults(activitiesData.data);
|
||||||
|
setLastUpdated(new Date().toLocaleTimeString());
|
||||||
|
} else {
|
||||||
|
throw new Error(activitiesData.error || 'Failed to fetch');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
const handleRecord = async (activity: string) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/activities/record', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ activity }),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
// Refresh data to update cooldown
|
||||||
|
await fetchData();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to record:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const locationName = config?.locationName || 'Livonia, MI';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto px-4 py-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h1 className="text-4xl font-bold text-white mb-2">
|
||||||
|
🌤️ Weather or Not
|
||||||
|
</h1>
|
||||||
|
<p className="text-slate-400">
|
||||||
|
Should you ride or mow today?
|
||||||
|
</p>
|
||||||
|
{lastUpdated && (
|
||||||
|
<p className="text-slate-500 text-sm mt-2">
|
||||||
|
Last checked: {lastUpdated}
|
||||||
|
<button
|
||||||
|
onClick={fetchData}
|
||||||
|
className="ml-2 text-blue-400 hover:text-blue-300"
|
||||||
|
>
|
||||||
|
↻ Refresh
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-4xl mb-4 animate-spin">🌀</div>
|
||||||
|
<p className="text-slate-400">Checking the weather...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="rounded-2xl bg-red-500/10 border border-red-500/30 p-6 text-center">
|
||||||
|
<p className="text-red-400 mb-2">⚠️ Error</p>
|
||||||
|
<p className="text-slate-400">{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={fetchData}
|
||||||
|
className="mt-4 px-4 py-2 bg-red-500/20 text-red-400 rounded-lg hover:bg-red-500/30"
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : weather ? (
|
||||||
|
<>
|
||||||
|
{/* Weather summary */}
|
||||||
|
<WeatherDisplay
|
||||||
|
temp={weather.temp}
|
||||||
|
weatherCode={weather.description}
|
||||||
|
isDay={1}
|
||||||
|
wind={weather.wind}
|
||||||
|
humidity={weather.humidity}
|
||||||
|
locationName={locationName}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Activity cards */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{results.map((result, idx) => (
|
||||||
|
<ActivityCard
|
||||||
|
key={result.name}
|
||||||
|
result={result}
|
||||||
|
onRecord={handleRecord}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="mt-8 text-center text-slate-500 text-sm">
|
||||||
|
<p>Data from Open-Meteo API • Free, no API key required</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
src/app/components/WeatherDisplay.tsx
Normal file
63
src/app/components/WeatherDisplay.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { getWeatherEmoji, getWeatherDescription } from '@/lib/weather';
|
||||||
|
|
||||||
|
interface WeatherDisplayProps {
|
||||||
|
temp: number;
|
||||||
|
weatherCode: number;
|
||||||
|
isDay: number;
|
||||||
|
wind: number;
|
||||||
|
humidity: number;
|
||||||
|
locationName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WeatherDisplay({
|
||||||
|
temp,
|
||||||
|
weatherCode,
|
||||||
|
isDay,
|
||||||
|
wind,
|
||||||
|
humidity,
|
||||||
|
locationName,
|
||||||
|
}: WeatherDisplayProps) {
|
||||||
|
const emoji = getWeatherEmoji(weatherCode, isDay);
|
||||||
|
const description = getWeatherDescription(weatherCode);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl bg-slate-800/50 border border-slate-700 p-6 mb-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-white mb-1">{locationName}</h2>
|
||||||
|
<p className="text-slate-400 text-sm">
|
||||||
|
{new Date().toLocaleDateString('en-US', {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-5xl font-bold">{emoji}</div>
|
||||||
|
<div className="text-3xl font-bold text-white">{Math.round(temp)}°C</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 grid grid-cols-3 gap-4 text-center">
|
||||||
|
<div className="bg-slate-700/50 rounded-lg p-3">
|
||||||
|
<div className="text-2xl mb-1">💨</div>
|
||||||
|
<div className="text-sm text-slate-400">Wind</div>
|
||||||
|
<div className="text-lg font-semibold">{wind} km/h</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-700/50 rounded-lg p-3">
|
||||||
|
<div className="text-2xl mb-1">💧</div>
|
||||||
|
<div className="text-sm text-slate-400">Humidity</div>
|
||||||
|
<div className="text-lg font-semibold">{humidity}%</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-700/50 rounded-lg p-3">
|
||||||
|
<div className="text-2xl mb-1">🌡️</div>
|
||||||
|
<div className="text-sm text-slate-400">Conditions</div>
|
||||||
|
<div className="text-sm font-semibold">{description}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
src/app/globals.css
Normal file
63
src/app/globals.css
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: #0f172a;
|
||||||
|
--foreground: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.text-balance {
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: #1e293b;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #475569;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animations */
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(10px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fade-in {
|
||||||
|
animation: fadeIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-green {
|
||||||
|
0%, 100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.4); }
|
||||||
|
50% { box-shadow: 0 0 0 10px rgba(34, 197, 94, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-red {
|
||||||
|
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); }
|
||||||
|
50% { box-shadow: 0 0 0 10px rgba(239, 68, 68, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-pulse-green {
|
||||||
|
animation: pulse-green 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-pulse-red {
|
||||||
|
animation: pulse-red 2s infinite;
|
||||||
|
}
|
||||||
21
src/app/layout.tsx
Normal file
21
src/app/layout.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import './globals.css';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Weather or Not',
|
||||||
|
description: 'Should I ride my motorcycle or mow the lawn today?',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body className="min-h-screen bg-slate-900 text-slate-100">
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
src/app/page.tsx
Normal file
9
src/app/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import ActivityCheck from './components/ActivityCheck';
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<ActivityCheck />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
190
src/lib/activities.ts
Normal file
190
src/lib/activities.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import { WeatherData, ActivityResult, RuleResult, ActivityConfig } from '@/types';
|
||||||
|
import { getWeatherDescription } from './weather';
|
||||||
|
import { daysSinceLastActivity } from './storage';
|
||||||
|
|
||||||
|
export function evaluateMotorcycle(
|
||||||
|
weather: WeatherData,
|
||||||
|
config: ActivityConfig,
|
||||||
|
): ActivityResult {
|
||||||
|
const rules: RuleResult[] = [];
|
||||||
|
const reasoning: string[] = [];
|
||||||
|
|
||||||
|
// Temperature
|
||||||
|
const temp = weather.current.temperature_2m;
|
||||||
|
const minTemp = config.min_temp_c ?? 10;
|
||||||
|
const maxTemp = config.max_temp_c ?? 35;
|
||||||
|
if (temp < minTemp) {
|
||||||
|
rules.push({ name: 'Temperature', passed: false, message: `${temp}°C is below minimum ${minTemp}°C` });
|
||||||
|
reasoning.push(`Too cold: ${temp}°C < ${minTemp}°C minimum`);
|
||||||
|
} else if (temp > maxTemp) {
|
||||||
|
rules.push({ name: 'Temperature', passed: false, message: `${temp}°C exceeds maximum ${maxTemp}°C` });
|
||||||
|
reasoning.push(`Too hot: ${temp}°C > ${maxTemp}°C maximum`);
|
||||||
|
} else {
|
||||||
|
rules.push({ name: 'Temperature', passed: true, message: `${temp}°C is comfortable` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Precipitation
|
||||||
|
const precip = weather.daily.precipitation_sum[0] ?? 0;
|
||||||
|
const maxPrecip = config.max_precipitation_mm ?? 0;
|
||||||
|
if (precip > maxPrecip) {
|
||||||
|
rules.push({ name: 'Precipitation', passed: false, message: `${precip}mm expected today` });
|
||||||
|
reasoning.push(`Rain expected: ${precip}mm`);
|
||||||
|
} else {
|
||||||
|
rules.push({ name: 'Precipitation', passed: true, message: 'No rain expected' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wind
|
||||||
|
const wind = weather.current.wind_speed_10m;
|
||||||
|
const maxWind = config.max_wind_speed_kmh ?? 30;
|
||||||
|
if (wind > maxWind) {
|
||||||
|
rules.push({ name: 'Wind', passed: false, message: `${wind} km/h exceeds safe limit` });
|
||||||
|
reasoning.push(`Wind too strong: ${wind} km/h`);
|
||||||
|
} else {
|
||||||
|
rules.push({ name: 'Wind', passed: true, message: `${wind} km/h is safe` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Humidity
|
||||||
|
const humidity = weather.current.relative_humidity_2m;
|
||||||
|
const maxHum = config.max_humidity_pct ?? 90;
|
||||||
|
if (humidity > maxHum) {
|
||||||
|
rules.push({ name: 'Humidity', passed: false, message: `${humidity}% is uncomfortable` });
|
||||||
|
reasoning.push(`High humidity: ${humidity}%`);
|
||||||
|
} else {
|
||||||
|
rules.push({ name: 'Humidity', passed: true, message: `${humidity}% is comfortable` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weather code (snow, ice, thunderstorm)
|
||||||
|
const code = weather.current.weather_code;
|
||||||
|
const dangerousCodes = [45, 48, 66, 67, 71, 73, 75, 77, 85, 86, 95, 96, 99];
|
||||||
|
if (dangerousCodes.includes(code)) {
|
||||||
|
rules.push({ name: 'Weather', passed: false, message: getWeatherDescription(code) });
|
||||||
|
reasoning.push(getWeatherDescription(code));
|
||||||
|
} else {
|
||||||
|
rules.push({ name: 'Weather', passed: true, message: getWeatherDescription(code) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const verdict = determineVerdict(rules);
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: 'Motorcycle Ride',
|
||||||
|
verdict,
|
||||||
|
rules,
|
||||||
|
reasoning,
|
||||||
|
icon: '🏍️',
|
||||||
|
description: 'Check conditions for motorcycle commuting',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function evaluateLawn(
|
||||||
|
weather: WeatherData,
|
||||||
|
config: ActivityConfig,
|
||||||
|
): ActivityResult {
|
||||||
|
const rules: RuleResult[] = [];
|
||||||
|
const reasoning: string[] = [];
|
||||||
|
|
||||||
|
// Cooldown check
|
||||||
|
const cooldownDays = config.cooldown_days ?? 3;
|
||||||
|
const daysSince = daysSinceLastActivity('mow_lawn');
|
||||||
|
if (daysSince < cooldownDays) {
|
||||||
|
rules.push({
|
||||||
|
name: 'Cooldown',
|
||||||
|
passed: false,
|
||||||
|
message: `Last mowed ${daysSince} day(s) ago (wait ${cooldownDays - daysSince} more)`,
|
||||||
|
});
|
||||||
|
reasoning.push(`Cooldown not met: ${daysSince} days since last mow (need ${cooldownDays})`);
|
||||||
|
} else {
|
||||||
|
rules.push({
|
||||||
|
name: 'Cooldown',
|
||||||
|
passed: true,
|
||||||
|
message: `Good — ${daysSince} days since last mow (minimum: ${cooldownDays})`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Today's precipitation
|
||||||
|
const precipToday = weather.daily.precipitation_sum[0] ?? 0;
|
||||||
|
const maxPrecip = config.max_precipitation_mm ?? 0;
|
||||||
|
if (precipToday > maxPrecip) {
|
||||||
|
rules.push({ name: 'Rain today', passed: false, message: `${precipToday}mm expected` });
|
||||||
|
reasoning.push(`Rain expected today: ${precipToday}mm`);
|
||||||
|
} else {
|
||||||
|
rules.push({ name: 'Rain today', passed: true, message: 'No rain expected' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Past 24h precipitation (ground dryness)
|
||||||
|
const precip24h = calculatePast24hPrecip(weather);
|
||||||
|
const max24h = config.max_precipitation_past_24h_mm ?? 5;
|
||||||
|
if (precip24h > max24h) {
|
||||||
|
rules.push({ name: 'Ground moisture', passed: false, message: `${precip24h}mm in past 24h` });
|
||||||
|
reasoning.push(`Ground too wet: ${precip24h}mm in past 24h`);
|
||||||
|
} else {
|
||||||
|
rules.push({ name: 'Ground moisture', passed: true, message: `Ground is dry (${precip24h}mm)` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wind
|
||||||
|
const wind = weather.current.wind_speed_10m;
|
||||||
|
const maxWind = config.max_wind_speed_kmh ?? 25;
|
||||||
|
if (wind > maxWind) {
|
||||||
|
rules.push({ name: 'Wind', passed: false, message: `${wind} km/h exceeds limit` });
|
||||||
|
reasoning.push(`Wind too strong: ${wind} km/h`);
|
||||||
|
} else {
|
||||||
|
rules.push({ name: 'Wind', passed: true, message: `${wind} km/h is safe` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Temperature
|
||||||
|
const temp = weather.current.temperature_2m;
|
||||||
|
const minTemp = config.min_temp_c ?? 15;
|
||||||
|
if (temp < minTemp) {
|
||||||
|
rules.push({ name: 'Temperature', passed: false, message: `${temp}°C is below ${minTemp}°C` });
|
||||||
|
reasoning.push(`Too cold: ${temp}°C`);
|
||||||
|
} else {
|
||||||
|
rules.push({ name: 'Temperature', passed: true, message: `${temp}°C is comfortable` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weather code
|
||||||
|
const code = weather.current.weather_code;
|
||||||
|
if (code <= 3) {
|
||||||
|
rules.push({ name: 'Weather', passed: true, message: getWeatherDescription(code) });
|
||||||
|
} else {
|
||||||
|
rules.push({ name: 'Weather', passed: false, message: getWeatherDescription(code) });
|
||||||
|
reasoning.push(getWeatherDescription(code));
|
||||||
|
}
|
||||||
|
|
||||||
|
const verdict = determineVerdict(rules);
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: 'Mow Lawn',
|
||||||
|
verdict,
|
||||||
|
rules,
|
||||||
|
reasoning,
|
||||||
|
icon: '🌱',
|
||||||
|
description: 'Check if conditions are good for mowing',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculatePast24hPrecip(weather: WeatherData): number {
|
||||||
|
const now = new Date();
|
||||||
|
let total = 0;
|
||||||
|
const hourly = weather.hourly;
|
||||||
|
for (let i = 0; i < hourly.time.length; i++) {
|
||||||
|
const time = new Date(hourly.time[i]);
|
||||||
|
const diffHours = (now.getTime() - time.getTime()) / (1000 * 60 * 60);
|
||||||
|
if (diffHours >= 0 && diffHours <= 24) {
|
||||||
|
total += hourly.precipitation[i] ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Math.round(total * 10) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
function determineVerdict(rules: RuleResult[]): 'GO' | 'NO-GO' | 'CAUTION' {
|
||||||
|
const failed = rules.filter(r => !r.passed);
|
||||||
|
if (failed.length === 0) return 'GO';
|
||||||
|
|
||||||
|
// Critical failures = rain/precipitation
|
||||||
|
const critical = failed.some(r =>
|
||||||
|
r.name.toLowerCase().includes('rain') ||
|
||||||
|
r.name.toLowerCase().includes('precip') ||
|
||||||
|
r.name.toLowerCase().includes('cooldown')
|
||||||
|
);
|
||||||
|
|
||||||
|
return critical ? 'NO-GO' : 'CAUTION';
|
||||||
|
}
|
||||||
108
src/lib/storage.ts
Normal file
108
src/lib/storage.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { AppConfig, ActivityHistory } from '@/types';
|
||||||
|
|
||||||
|
const STORAGE_KEYS = {
|
||||||
|
CONFIG: 'won_config',
|
||||||
|
HISTORY: 'won_history',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function loadConfig(): AppConfig {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return getDefaultConfig();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEYS.CONFIG);
|
||||||
|
if (stored) {
|
||||||
|
return JSON.parse(stored);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load config:', e);
|
||||||
|
}
|
||||||
|
return getDefaultConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveConfig(config: AppConfig): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEYS.CONFIG, JSON.stringify(config));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to save config:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadHistory(): ActivityHistory[] {
|
||||||
|
if (typeof window === 'undefined') return [];
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEYS.HISTORY);
|
||||||
|
return stored ? JSON.parse(stored) : [];
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load history:', e);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveHistory(history: ActivityHistory[]): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEYS.HISTORY, JSON.stringify(history));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to save history:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordActivity(activity: string): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
const history = loadHistory();
|
||||||
|
history.push({
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
activity,
|
||||||
|
performed: true,
|
||||||
|
});
|
||||||
|
// Keep only last 100 entries
|
||||||
|
if (history.length > 100) {
|
||||||
|
history.splice(0, history.length - 100);
|
||||||
|
}
|
||||||
|
saveHistory(history);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLastActivityDate(activity: string): string | null {
|
||||||
|
const history = loadHistory();
|
||||||
|
const entries = history
|
||||||
|
.filter(h => h.activity === activity)
|
||||||
|
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
||||||
|
return entries.length > 0 ? entries[0].timestamp : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function daysSinceLastActivity(activity: string): number {
|
||||||
|
const lastDate = getLastActivityDate(activity);
|
||||||
|
if (!lastDate) return 999;
|
||||||
|
const last = new Date(lastDate);
|
||||||
|
const now = new Date();
|
||||||
|
const diffTime = Math.abs(now.getTime() - last.getTime());
|
||||||
|
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
|
||||||
|
return diffDays;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaultConfig(): AppConfig {
|
||||||
|
return {
|
||||||
|
latitude: 42.3665,
|
||||||
|
longitude: -83.3957,
|
||||||
|
locationName: 'Livonia, MI',
|
||||||
|
activities: {
|
||||||
|
motorcycle_ride: {
|
||||||
|
enabled: true,
|
||||||
|
min_temp_c: 10,
|
||||||
|
max_temp_c: 35,
|
||||||
|
max_precipitation_mm: 0,
|
||||||
|
max_wind_speed_kmh: 30,
|
||||||
|
max_humidity_pct: 90,
|
||||||
|
},
|
||||||
|
mow_lawn: {
|
||||||
|
enabled: true,
|
||||||
|
cooldown_days: 3,
|
||||||
|
max_precipitation_mm: 0,
|
||||||
|
max_wind_speed_kmh: 25,
|
||||||
|
max_precipitation_past_24h_mm: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
65
src/lib/weather.ts
Normal file
65
src/lib/weather.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { WeatherData } from '@/types';
|
||||||
|
|
||||||
|
const OPEN_METEO_BASE = 'https://api.open-meteo.com/v1/forecast';
|
||||||
|
|
||||||
|
export async function fetchWeather(
|
||||||
|
latitude: number,
|
||||||
|
longitude: number,
|
||||||
|
): Promise<WeatherData> {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
latitude: latitude.toString(),
|
||||||
|
longitude: longitude.toString(),
|
||||||
|
current: 'temperature_2m,relative_humidity_2m,precipitation,weather_code,wind_speed_10m,wind_direction_10m,is_day,pressure_msl',
|
||||||
|
hourly: 'temperature_2m,relative_humidity_2m,precipitation,wind_speed_10m,weather_code',
|
||||||
|
daily: 'precipitation_sum,temperature_2m_max,temperature_2m_min',
|
||||||
|
timezone: 'auto',
|
||||||
|
forecast_days: '3',
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = `${OPEN_METEO_BASE}?${params}`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Weather API error: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data as WeatherData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWeatherDescription(code: number): string {
|
||||||
|
const descriptions: Record<number, string> = {
|
||||||
|
0: 'Clear sky',
|
||||||
|
1: 'Mainly clear',
|
||||||
|
2: 'Partly cloudy',
|
||||||
|
3: 'Overcast',
|
||||||
|
45: 'Fog',
|
||||||
|
48: 'Depositing rime fog',
|
||||||
|
51: 'Light drizzle',
|
||||||
|
53: 'Moderate drizzle',
|
||||||
|
55: 'Dense drizzle',
|
||||||
|
61: 'Slight rain',
|
||||||
|
63: 'Moderate rain',
|
||||||
|
65: 'Heavy rain',
|
||||||
|
71: 'Slight snowfall',
|
||||||
|
73: 'Moderate snowfall',
|
||||||
|
75: 'Heavy snowfall',
|
||||||
|
95: 'Thunderstorm',
|
||||||
|
};
|
||||||
|
return descriptions[code] || `Weather code ${code}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWeatherEmoji(code: number, isDay: number): string {
|
||||||
|
if (!isDay) return '🌙';
|
||||||
|
if (code === 0) return '☀️';
|
||||||
|
if (code === 1) return '🌤️';
|
||||||
|
if (code === 2) return '⛅';
|
||||||
|
if (code === 3) return '☁️';
|
||||||
|
if (code >= 45 && code <= 48) return '🌫️';
|
||||||
|
if (code >= 51 && code <= 67) return '🌧️';
|
||||||
|
if (code >= 71 && code <= 77) return '🌨️';
|
||||||
|
if (code >= 80 && code <= 82) return '🌦️';
|
||||||
|
if (code >= 85 && code <= 86) return '🌨️';
|
||||||
|
if (code >= 95) return '⛈️';
|
||||||
|
return '🌤️';
|
||||||
|
}
|
||||||
71
src/types/index.ts
Normal file
71
src/types/index.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
export interface WeatherData {
|
||||||
|
current: {
|
||||||
|
temperature_2m: number;
|
||||||
|
relative_humidity_2m: number;
|
||||||
|
precipitation: number;
|
||||||
|
weather_code: number;
|
||||||
|
wind_speed_10m: number;
|
||||||
|
wind_direction_10m: number;
|
||||||
|
is_day: number;
|
||||||
|
};
|
||||||
|
hourly: {
|
||||||
|
time: string[];
|
||||||
|
temperature_2m: number[];
|
||||||
|
relative_humidity_2m: number[];
|
||||||
|
precipitation: number[];
|
||||||
|
wind_speed_10m: number[];
|
||||||
|
weather_code: number[];
|
||||||
|
};
|
||||||
|
daily: {
|
||||||
|
time: string[];
|
||||||
|
precipitation_sum: number[];
|
||||||
|
temperature_2m_max: number[];
|
||||||
|
temperature_2m_min: number[];
|
||||||
|
};
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
timezone: string;
|
||||||
|
generationtime_ms: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivityConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
min_temp_c?: number;
|
||||||
|
max_temp_c?: number;
|
||||||
|
max_precipitation_mm?: number;
|
||||||
|
max_wind_speed_kmh?: number;
|
||||||
|
max_humidity_pct?: number;
|
||||||
|
cooldown_days?: number;
|
||||||
|
max_precipitation_past_24h_mm?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivityResult {
|
||||||
|
name: string;
|
||||||
|
verdict: 'GO' | 'NO-GO' | 'CAUTION';
|
||||||
|
rules: RuleResult[];
|
||||||
|
reasoning: string[];
|
||||||
|
icon: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuleResult {
|
||||||
|
name: string;
|
||||||
|
passed: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivityHistory {
|
||||||
|
timestamp: string;
|
||||||
|
activity: string;
|
||||||
|
performed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppConfig {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
locationName: string;
|
||||||
|
activities: {
|
||||||
|
motorcycle_ride: ActivityConfig;
|
||||||
|
mow_lawn: ActivityConfig;
|
||||||
|
};
|
||||||
|
}
|
||||||
26
tailwind.config.ts
Normal file
26
tailwind.config.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import type { Config } from 'tailwindcss';
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
content: ['./src/**/*.{js,ts,jsx,tsx,mdx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
primary: {
|
||||||
|
50: '#eff6ff',
|
||||||
|
100: '#dbeafe',
|
||||||
|
200: '#bfdbfe',
|
||||||
|
300: '#93c5fd',
|
||||||
|
400: '#60a5fa',
|
||||||
|
500: '#3b82f6',
|
||||||
|
600: '#2563eb',
|
||||||
|
700: '#1d4ed8',
|
||||||
|
800: '#1e40af',
|
||||||
|
900: '#1e3a8a',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
22
tsconfig.json
Normal file
22
tsconfig.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [{ "name": "next" }],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user