Rewrite as Next.js app with Livonia, MI location

This commit is contained in:
2026-04-29 04:53:43 +00:00
commit 652cfbda8a
21 changed files with 1210 additions and 0 deletions

36
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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>
);
}

View 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>
);
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"]
}