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