From 652cfbda8a5af990dbccb77b7d776ca9d6d62afa Mon Sep 17 00:00:00 2001 From: vidane Date: Wed, 29 Apr 2026 04:53:43 +0000 Subject: [PATCH] Rewrite as Next.js app with Livonia, MI location --- .gitignore | 36 +++++ README.md | 60 ++++++++ config.yaml | 43 ++++++ next.config.js | 13 ++ package.json | 26 ++++ postcss.config.js | 6 + src/app/api/activities/record/route.ts | 59 ++++++++ src/app/api/activities/route.ts | 43 ++++++ src/app/api/weather/route.ts | 23 +++ src/app/components/ActivityCard.tsx | 115 +++++++++++++++ src/app/components/ActivityCheck.tsx | 148 +++++++++++++++++++ src/app/components/WeatherDisplay.tsx | 63 ++++++++ src/app/globals.css | 63 ++++++++ src/app/layout.tsx | 21 +++ src/app/page.tsx | 9 ++ src/lib/activities.ts | 190 +++++++++++++++++++++++++ src/lib/storage.ts | 108 ++++++++++++++ src/lib/weather.ts | 65 +++++++++ src/types/index.ts | 71 +++++++++ tailwind.config.ts | 26 ++++ tsconfig.json | 22 +++ 21 files changed, 1210 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 config.yaml create mode 100644 next.config.js create mode 100644 package.json create mode 100644 postcss.config.js create mode 100644 src/app/api/activities/record/route.ts create mode 100644 src/app/api/activities/route.ts create mode 100644 src/app/api/weather/route.ts create mode 100644 src/app/components/ActivityCard.tsx create mode 100644 src/app/components/ActivityCheck.tsx create mode 100644 src/app/components/WeatherDisplay.tsx create mode 100644 src/app/globals.css create mode 100644 src/app/layout.tsx create mode 100644 src/app/page.tsx create mode 100644 src/lib/activities.ts create mode 100644 src/lib/storage.ts create mode 100644 src/lib/weather.ts create mode 100644 src/types/index.ts create mode 100644 tailwind.config.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6309728 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..f3d829a --- /dev/null +++ b/README.md @@ -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 diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..eb36484 --- /dev/null +++ b/config.yaml @@ -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) diff --git a/next.config.js b/next.config.js new file mode 100644 index 0000000..04fe6a6 --- /dev/null +++ b/next.config.js @@ -0,0 +1,13 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + images: { + remotePatterns: [ + { + protocol: 'https', + hostname: 'openweathermap.org', + }, + ], + }, +}; + +module.exports = nextConfig; diff --git a/package.json b/package.json new file mode 100644 index 0000000..efea3b1 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..12a703d --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/src/app/api/activities/record/route.ts b/src/app/api/activities/record/route.ts new file mode 100644 index 0000000..c153553 --- /dev/null +++ b/src/app/api/activities/record/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/activities/route.ts b/src/app/api/activities/route.ts new file mode 100644 index 0000000..a0529f4 --- /dev/null +++ b/src/app/api/activities/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/weather/route.ts b/src/app/api/weather/route.ts new file mode 100644 index 0000000..8cb1558 --- /dev/null +++ b/src/app/api/weather/route.ts @@ -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 } + ); + } +} diff --git a/src/app/components/ActivityCard.tsx b/src/app/components/ActivityCard.tsx new file mode 100644 index 0000000..dd0f19a --- /dev/null +++ b/src/app/components/ActivityCard.tsx @@ -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 ( + + + GO + + ); + if (isCaution) return ( + + + CAUTION + + ); + return ( + + + NO-GO + + ); + }; + + const handleRecord = async () => { + setRecording(true); + try { + await onRecord(result.name.toLowerCase().includes('motorcycle') ? 'motorcycle_ride' : 'mow_lawn'); + } finally { + setRecording(false); + } + }; + + return ( +
+
+
+ {result.icon} +
+

{result.name}

+

{result.description}

+
+
+ {getStatusBadge()} +
+ + {/* Rule results */} +
+ {result.rules.map((rule, idx) => ( +
+ + {rule.passed ? '✓' : '✗'} + + {rule.name} + {rule.message} +
+ ))} +
+ + {/* Toggle details */} + {result.reasoning.length > 0 && ( + + )} + + {showDetails && ( +
+

+ Why?{' '} + {result.reasoning.join('. ')} +

+
+ )} + + {/* Record button */} + +
+ ); +} diff --git a/src/app/components/ActivityCheck.tsx b/src/app/components/ActivityCheck.tsx new file mode 100644 index 0000000..8db5e52 --- /dev/null +++ b/src/app/components/ActivityCheck.tsx @@ -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([]); + const [weather, setWeather] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [lastUpdated, setLastUpdated] = useState(''); + const [config, setConfig] = useState(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 ( +
+ {/* Header */} +
+

+ 🌤️ Weather or Not +

+

+ Should you ride or mow today? +

+ {lastUpdated && ( +

+ Last checked: {lastUpdated} + +

+ )} +
+ + {loading ? ( +
+
+
🌀
+

Checking the weather...

+
+
+ ) : error ? ( +
+

⚠️ Error

+

{error}

+ +
+ ) : weather ? ( + <> + {/* Weather summary */} + + + {/* Activity cards */} +
+ {results.map((result, idx) => ( + + ))} +
+ + {/* Footer */} +
+

Data from Open-Meteo API • Free, no API key required

+
+ + ) : null} +
+ ); +} diff --git a/src/app/components/WeatherDisplay.tsx b/src/app/components/WeatherDisplay.tsx new file mode 100644 index 0000000..59e57d2 --- /dev/null +++ b/src/app/components/WeatherDisplay.tsx @@ -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 ( +
+
+
+

{locationName}

+

+ {new Date().toLocaleDateString('en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + })} +

+
+
+
{emoji}
+
{Math.round(temp)}°C
+
+
+
+
+
💨
+
Wind
+
{wind} km/h
+
+
+
💧
+
Humidity
+
{humidity}%
+
+
+
🌡️
+
Conditions
+
{description}
+
+
+
+ ); +} diff --git a/src/app/globals.css b/src/app/globals.css new file mode 100644 index 0000000..ce77215 --- /dev/null +++ b/src/app/globals.css @@ -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; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000..848d0a5 --- /dev/null +++ b/src/app/layout.tsx @@ -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 ( + + + {children} + + + ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx new file mode 100644 index 0000000..0154863 --- /dev/null +++ b/src/app/page.tsx @@ -0,0 +1,9 @@ +import ActivityCheck from './components/ActivityCheck'; + +export default function Home() { + return ( +
+ +
+ ); +} diff --git a/src/lib/activities.ts b/src/lib/activities.ts new file mode 100644 index 0000000..461bee6 --- /dev/null +++ b/src/lib/activities.ts @@ -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'; +} diff --git a/src/lib/storage.ts b/src/lib/storage.ts new file mode 100644 index 0000000..a5920ac --- /dev/null +++ b/src/lib/storage.ts @@ -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, + }, + }, + }; +} diff --git a/src/lib/weather.ts b/src/lib/weather.ts new file mode 100644 index 0000000..7e17cf4 --- /dev/null +++ b/src/lib/weather.ts @@ -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 { + 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 = { + 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 '🌤️'; +} diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..dfca224 --- /dev/null +++ b/src/types/index.ts @@ -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; + }; +} diff --git a/tailwind.config.ts b/tailwind.config.ts new file mode 100644 index 0000000..46e37af --- /dev/null +++ b/tailwind.config.ts @@ -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; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2aead53 --- /dev/null +++ b/tsconfig.json @@ -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"] +}