This commit is contained in:
Waylon Walker 2024-05-08 20:45:33 -05:00
commit 0500266b92
No known key found for this signature in database
GPG key ID: 66E2BF2B4190EFE4
21 changed files with 1766 additions and 0 deletions

46
justfile Normal file
View file

@ -0,0 +1,46 @@
default: build tag push convert set-image deploy
fresh: create-ns cred convert deploy viz
update: convert patch
regcred:
kubectl get secret -n default regcred --output=yaml -o yaml | sed 's/namespace: default/namespace: play-outside/' | kubectl apply -n play-outside -f - && echo deployed secret || echo secret exists
build:
podman build -t docker.io/waylonwalker/play-outside -f Dockerfile .
tag:
podman tag docker.io/waylonwalker/play-outside docker.io/waylonwalker/play-outside:$(hatch version)
push:
podman push docker.io/waylonwalker/play-outside docker.io/waylonwalker/play-outside:$(hatch version)
podman push docker.io/waylonwalker/play-outside docker.io/waylonwalker/play-outside:latest
set-image:
kubectl set image deployment/play-outside-wayl-one --namespace play-outside play-outside-wayl-one=docker.io/waylonwalker/play-outside:$(hatch version)
create-ns:
kubectl create ns play-outside && echo created ns play-outside || echo namespace play-outside already exists
cred:
kubectl get secret regcred --output=yaml -o yaml | sed 's/namespace: default/namespace: play-outside/' | kubectl apply -n play-outside -f - && echo deployed secret || echo secret exists
convert:
kompose convert -o deployment.yaml -n play-outside --replicas 3
deploy:
kubectl apply -f deployment.yaml
delete:
kubectl delete all --all -n play-outside --timeout=0s
viz:
k8sviz -n play-outside --kubeconfig $KUBECONFIG -t png -o play-outside-k8s.png
restart:
kubectl rollout restart -n play-outside deployment/play-outside-wayl-one
patch:
kubectl patch -f deployment.yaml
describe:
kubectl get deployment -n play-outside
kubectl get rs -n play-outside
kubectl get pod -n play-outside
kubectl get svc -n play-outside
kubectl get ing -n play-outside
describe-pod:
kubectl describe pod -n play-outside
logs:
kubectl logs --all-containers -l io.kompose.service=play-outside-wayl-one -n play-outside -f

View file

@ -0,0 +1,4 @@
# SPDX-FileCopyrightText: 2024-present Waylon S. Walker <waylon@waylonwalker.com>
#
# SPDX-License-Identifier: MIT
__version__ = "0.0.28"

3
play_outside/__init__.py Normal file
View file

@ -0,0 +1,3 @@
# SPDX-FileCopyrightText: 2024-present Waylon S. Walker <waylon@waylonwalker.com>
#
# SPDX-License-Identifier: MIT

270
play_outside/api.py Normal file
View file

@ -0,0 +1,270 @@
from dataclasses import dataclass
from fastapi.responses import PlainTextResponse
from fastapi import FastAPI
import time
from datetime import datetime
from fastapi import Request
from fastapi import Header
import httpx
from play_outside.config import get_config
from play_outside.decorators import cache, no_cache
from fastapi.responses import FileResponse
from fastapi.responses import JSONResponse
from fastapi.responses import RedirectResponse
from fastapi import Depends
import arel
def set_prefers(
request: Request,
):
hx_request_header = request.headers.get("hx-request")
user_agent = request.headers.get("user-agent", "").lower()
if "mozilla" in user_agent or "webkit" in user_agent or hx_request_header:
request.state.prefers_html = True
request.state.prefers_json = False
else:
request.state.prefers_html = False
request.state.prefers_json = True
app = FastAPI(
dependencies=[Depends(set_prefers)],
)
config = get_config()
if config.env == "local":
hot_reload = arel.HotReload(
paths=[arel.Path("fokais"), arel.Path("templates"), arel.Path("static")]
)
app.add_websocket_route("/hot-reload", route=hot_reload, name="hot-reload")
app.add_event_handler("startup", hot_reload.startup)
app.add_event_handler("shutdown", hot_reload.shutdown)
config.templates.env.globals["DEBUG"] = True
config.templates.env.globals["hot_reload"] = hot_reload
async def get_lat_long(ip_address):
if ip_address is None:
ip_address = "140.177.140.75"
async with httpx.AsyncClient() as client:
response = await client.get(f"https://ipwho.is/{ip_address}")
return response.json()
async def get_weather(lat_long=None):
if not lat_long:
lat_long = {"latitude": 40.7128, "longitude": -74.0060}
async with httpx.AsyncClient() as client:
response = await client.get(
f"https://api.openweathermap.org/data/2.5/weather?units=imperial&lat={lat_long['latitude']}&lon={lat_long['longitude']}&appid={config.open_weather_api_key}"
)
return response.json()
async def get_forecast(lat_long=None):
if not lat_long:
lat_long = {"latitude": 40.7128, "longitude": -74.0060}
async with httpx.AsyncClient() as client:
response = await client.get(
f"https://api.openweathermap.org/data/2.5/forecast?units=imperial&lat={lat_long['latitude']}&lon={lat_long['longitude']}&appid={config.open_weather_api_key}"
)
return response.json()["list"]
async def get_air_quality(lat_long):
async with httpx.AsyncClient() as client:
response = await client.get(
f"https://api.openweathermap.org/data/2.5/air_pollution?lat={lat_long['latitude']}&lon={lat_long['longitude']}&appid={config.open_weather_api_key}"
)
return response.json()
@app.get("/htmx.org@1.9.8", include_in_schema=False)
@cache()
async def get_htmx(request: Request):
return FileResponse("static/htmx.org@1.9.8")
@app.get("/favicon.ico", include_in_schema=False)
@cache()
async def get_favicon(request: Request):
return FileResponse("static/favicon.ico")
@app.get("/app3.css", include_in_schema=False)
@cache()
async def get_app_css(request: Request):
"""
return app.css for development updates
"""
return FileResponse("static/app.css")
def make_text_response(request, data, color):
template_response = config.templates.TemplateResponse(
"index.txt", {"request": request, "color": color, **data}
)
return PlainTextResponse(template_response.body, status_code=200)
@app.get("/")
@no_cache
async def get_home(request: Request, color: bool = False, n_forecast: int = None):
data = await get_data(request, n_forecast)
print(n_forecast)
print(len(data["forecast"]))
if request.state.prefers_html:
return config.templates.TemplateResponse(
"index.html", {"request": request, **data}
)
else:
return make_text_response(request, data, color=color)
def hours_till_sunset(weather):
if "sys" not in weather:
return ""
if "sunset" not in weather["sys"]:
return ""
sunset = (
datetime.fromtimestamp(weather["sys"]["sunset"])
- datetime.fromtimestamp(weather["dt"])
).total_seconds() / 60
if sunset < 0:
return "it is after sunset"
elif sunset > 120:
return ""
elif sunset > 60:
return f"Time till sunset is {round(int(sunset)/60)} hours. "
else:
return f"Time till sunset is {round(int(sunset))} minutes. "
ANSI_RED = r"\x1b[1;31m"
ANSI_GREEN = r"\x1b[1;32m"
ANSI_YELLOW = r"\x1b[1;33m"
@dataclass
class PlayCondition:
message: str = ""
color: str = "bg-green-500"
ansi_color: str = ANSI_GREEN
def determine_play_condition(weather, aqi=0):
play_condition = PlayCondition()
feels_like_temperature = weather["main"]["feels_like"]
# visibility = weather["visibility"]
play_condition.message += hours_till_sunset(weather)
if "after" in play_condition.message:
play_condition.color = "bg-red-500"
play_condition.ansi_color = ANSI_RED
# if visibility < 1000:
# play_condition.message += "It's too foggy. Find better activities inside!"
# play_condition.color = "bg-red-500"
# play_condition.ansi_color = ANSI_RED
if aqi > 150:
play_condition.message += "It's too polluted. Find better activities inside!"
play_condition.color = "bg-red-500"
play_condition.ansi_color = ANSI_RED
elif aqi > 100:
play_condition.message += "limit your time outside due to the poor air quality"
play_condition.color = "bg-yellow-500"
play_condition.ansi_color = ANSI_YELLOW
elif aqi > 50:
play_condition.message += "Check the air quality outside at your discression."
play_condition.color = "bg-yellow-500"
play_condition.ansi_color = ANSI_YELLOW
else:
play_condition.message += ""
if feels_like_temperature < 10:
play_condition.message += "It's too cold. Stay indoors and keep warm!"
play_condition.color = "bg-red-500"
play_condition.ansi_color = ANSI_RED
elif feels_like_temperature < 30:
play_condition.message += (
"You can play outside, but limit your time in this cold!"
)
play_condition.color = "bg-yellow-500"
play_condition.ansi_color = ANSI_YELLOW
elif feels_like_temperature < 40:
play_condition.message += (
"Coats and winter gear required for outdoor play. Stay cozy!"
)
elif feels_like_temperature < 50:
play_condition.message += "Grab a warm jacket and enjoy your time outside!"
elif feels_like_temperature < 60:
play_condition.message += "Grab some long sleeves and enjoy your time outside!"
elif feels_like_temperature > 90:
play_condition.message += (
"You can play outside, but limit your time in this heat!"
)
play_condition.color = "bg-yellow-500"
play_condition.ansi_color = ANSI_YELLOW
elif feels_like_temperature > 109:
play_condition.message += (
"It's too hot for outdoor play. Find cooler activities indoors!"
)
play_condition.color = "bg-red-500"
play_condition.ansi_color = ANSI_RED
else:
play_condition.message += "Enjoy your time outside!"
return play_condition
async def get_data(request: Request, n_forecast: int = None):
user_ip = request.headers.get("CF-Connecting-IP")
lat_long = await get_lat_long(user_ip)
weather = await get_weather(lat_long)
forecast = await get_forecast(lat_long)
if n_forecast is not None:
forecast = forecast[: n_forecast + 2]
air_quality = await get_air_quality(lat_long)
weather["play_condition"] = determine_play_condition(
weather,
air_quality["list"][0]["main"]["aqi"],
)
forecast = [
{"play_condition": determine_play_condition(x), **x}
for x in forecast
if datetime.fromtimestamp(x["dt"]).hour >= 6
and datetime.fromtimestamp(x["dt"]).hour <= 21
]
return {
"request.client": request.client,
"request.client.host": request.client.host,
"user_ip": user_ip,
"lat_long": lat_long,
"weather": weather,
"forecast": forecast,
"air_quality": air_quality,
"sunset": weather["sys"]["sunset"],
}
@app.get("/metadata")
async def root(
request: Request,
):
return await get_data(request)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8100)

133
play_outside/cli/api.py Normal file
View file

@ -0,0 +1,133 @@
from pathlib import Path
import typer
import uvicorn
from rich.console import Console
from play_outside.config import get_config
api_app = typer.Typer()
@api_app.callback()
def api():
"model cli"
@api_app.command()
def config(
env: str = typer.Option(
help="the environment to use",
),
):
play_outside_config = get_config(env)
Console().print(play_outside_config)
@api_app.command()
def upgrade(
env: str = typer.Option(
help="the environment to use",
),
alembic_revision: str = typer.Option(
"head",
help="the alembic revision to use",
),
):
play_outside_config = get_config(env)
Console().print(play_outside_config)
alembic_cfg = Config("alembic.ini")
alembic_cfg.set_main_option("sqlalchemy.url", play_outside_config.database_url)
alembic.command.upgrade(config=alembic_cfg, revision=alembic_revision)
@api_app.command()
def revision(
env: str = typer.Option(
help="the environment to use",
),
alembic_revision: str = typer.Option(
"head",
help="the alembic revision to upgrade to before creating a new revision",
),
message: str = typer.Option(
None,
"--message",
"-m",
help="the message to use for the new revision",
),
):
play_outside_config = get_config(env)
Console().print(play_outside_config)
alembic_cfg = Config("alembic.ini")
alembic_cfg.set_main_option("sqlalchemy.url", play_outside_config.database_url)
alembic.command.upgrade(config=alembic_cfg, revision=alembic_revision)
alembic.command.revision(
config=alembic_cfg,
message=message,
autogenerate=True,
)
alembic.command.upgrade(config=alembic_cfg, revision="head")
@api_app.command()
def datasette(
env: str = typer.Option(
help="the environment to use",
),
alembic_revision: str = typer.Option(
"head",
help="the alembic revision to use",
),
):
from datasette.app import Datasette
play_outside_config = get_config(env)
Console().print(play_outside_config)
alembic_cfg = Config("alembic.ini")
alembic_cfg.set_main_option("sqlalchemy.url", play_outside_config.database_url)
alembic.command.upgrade(config=alembic_cfg, revision=alembic_revision)
ds = Datasette(
files=[Path(play_outside_config.database_url.replace("sqlite:///", ""))],
)
uvicorn.run(
ds.app(), **play_outside_config.datasette_server.dict(exclude={"app", "reload"})
)
@api_app.command()
def run(
env: str = typer.Option(
help="the environment to use",
),
alembic_revision: str = typer.Option(
"head",
help="the alembic revision to use",
),
):
play_outside_config = get_config(env)
Console().print(play_outside_config)
# alembic_cfg = Config("alembic.ini")
# alembic_cfg.set_main_option("sqlalchemy.url", play_outside_config.database_url)
# alembic.command.upgrade(config=alembic_cfg, revision=alembic_revision)
uvicorn.run(**play_outside_config.api_server.dict())
@api_app.command()
def justrun(
env: str = typer.Option(
"local",
help="the environment to use",
),
alembic_revision: str = typer.Option(
"head",
help="the alembic revision to use",
),
):
play_outside_config = get_config(env)
Console().print(play_outside_config)
uvicorn.run(**play_outside_config.api_server.dict())
if __name__ == "__main__":
api_app()

98
play_outside/config.py Normal file
View file

@ -0,0 +1,98 @@
import pydantic
from dotenv import load_dotenv
from pydantic_settings import BaseSettings
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import BaseModel
from pydantic import Field
from typing import Any
from fastapi.templating import Jinja2Templates
from urllib.parse import quote_plus
from typing import Optional
from datetime import datetime
from datetime import timezone
from functools import lru_cache
import os
import jinja2
from rich.console import Console
console = Console()
class ApiServer(BaseModel):
app: str = "play_outside.api:app"
port: int = 8200
reload: bool = True
log_level: str = "info"
host: str = "0.0.0.0"
workers: int = 1
forwarded_allow_ips: str = "*"
proxy_headers: bool = True
if hasattr(jinja2, "pass_context"):
pass_context = jinja2.pass_context
else:
pass_context = jinja2.contextfunction
@pass_context
def https_url_for(context: dict, name: str, **path_params: Any) -> str:
request = context["request"]
http_url = request.url_for(name, **path_params)
return str(http_url).replace("http", "https", 1)
def get_templates(config: BaseSettings) -> Jinja2Templates:
templates = Jinja2Templates(directory="templates")
templates.env.filters["quote_plus"] = lambda u: quote_plus(str(u))
templates.env.filters["timestamp"] = lambda u: datetime.fromtimestamp(
u, tz=timezone.utc
).strftime("%B %d, %Y")
templates.env.globals["https_url_for"] = https_url_for
templates.env.globals["config"] = config
templates.env.globals["datetime"] = datetime
templates.env.globals["len"] = len
templates.env.globals["int"] = int
console.print(f'Using environment: {os.environ.get("ENV")}')
if os.environ.get("ENV") in ["dev", "qa", "prod"]:
templates.env.globals["url_for"] = https_url_for
console.print("Using HTTPS")
else:
console.print("Using HTTP")
return templates
class Config(BaseSettings):
env: str = "prod"
open_weather_api_key: Optional[str] = None
api_server: ApiServer = ApiServer()
the_templates: Optional[Jinja2Templates] = Field(None, exclude=True)
model_config = SettingsConfigDict(
env_prefix="PLAY_OUTSIDE_",
env_nested_delimiter="__",
case_sensitive=False,
)
@property
def templates(self) -> Jinja2Templates:
if self.the_templates is None:
self.the_templates = get_templates(self)
return self.the_templates
@pydantic.validator("open_weather_api_key", pre=True, always=True)
def validate_open_weather_api_key(cls, v):
if v is None:
v = os.getenv("OPEN_WEATHER_API_KEY")
return v
@lru_cache
def get_config(env: Optional[str] = None):
if env is None:
env = os.environ.get("ENV", "prod")
load_dotenv(dotenv_path=f".env.{env}")
config = Config(env=env)
return config

104
play_outside/decorators.py Normal file
View file

@ -0,0 +1,104 @@
import inspect
import time
from functools import wraps
from inspect import signature
from fastapi import Request
from fastapi.responses import JSONResponse
from play_outside.config import get_config
config = get_config()
admin_routes = []
not_cached_routes = []
cached_routes = []
def not_found(request):
hx_request_header = request.headers.get("hx-request")
user_agent = request.headers.get("user-agent", "").lower()
if "mozilla" in user_agent or "webkit" in user_agent or hx_request_header:
return config.templates.TemplateResponse(
"error.html",
{"status_code": 404, "detail": "Not Found", "request": request},
status_code=404,
)
else:
return JSONResponse(
content={
"status_code": 404,
"detail": "Not Found",
},
status_code=404,
)
def no_cache(func):
not_cached_routes.append(f"{func.__module__}.{func.__name__}")
@wraps(func)
async def wrapper(*args, request: Request, **kwargs):
# my_header will be now available in decorator
if "request" in signature(func).parameters:
kwargs["request"] = request
if inspect.iscoroutinefunction(func):
response = await func(*args, **kwargs)
else:
response = func(*args, **kwargs)
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
return response
return wrapper
def cache(max_age=86400):
def inner_wrapper(func):
cached_routes.append(f"{func.__module__}.{func.__name__}")
@wraps(func)
async def wrapper(*args, request: Request, **kwargs):
if "request" in signature(func).parameters:
kwargs["request"] = request
if inspect.iscoroutinefunction(func):
response = await func(*args, **kwargs)
else:
response = func(*args, **kwargs)
response.headers[
"Cache-Control"
] = f"public, max-age={max_age}, stale-while-revalidate=31536000, stale-if-error=31536000"
response.headers["Expires"] = f"{int(time.time()) + max_age}"
return response
return wrapper
return inner_wrapper
default_data = {}
def defaults(data=default_data):
def inner_wrapper(func):
default_data[f"{func.__module__}.{func.__name__}"] = data
@wraps(func)
async def wrapper(*args, request: Request, **kwargs):
if "request" in signature(func).parameters:
kwargs["request"] = request
if inspect.iscoroutinefunction(func):
response = await func(*args, **kwargs)
else:
response = func(*args, **kwargs)
return response
return wrapper
return inner_wrapper

4
play_outside/queries.py Normal file
View file

@ -0,0 +1,4 @@
import httpx
# get weather from open weather api

79
pyproject.toml Normal file
View file

@ -0,0 +1,79 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["play_outside"]
[project]
name = "play-outside"
dynamic = ["version"]
description = 'Use Open Weather Api to determine if Kids can play outside.'
readme = "README.md"
requires-python = ">=3.8"
license = "MIT"
keywords = []
authors = [{ name = "Waylon S. Walker", email = "waylon@waylonwalker.com" }]
classifiers = [
"Development Status :: 4 - Beta",
"Programming Language :: Python",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Python :: Implementation :: PyPy",
]
dependencies = [
'httpx',
'fastapi',
'uvicorn[standard]',
'typer',
'python-dotenv',
'rich',
'pydantic-settings',
'jinja2',
'arel',
]
[project.urls]
Documentation = "https://github.com/unknown/play-outside#readme"
Issues = "https://github.com/unknown/play-outside/issues"
Source = "https://github.com/unknown/play-outside"
[project.scripts]
play-outside = "play_outside.cli.api:api_app"
[tool.hatch.version]
path = "play_outside/__about__.py"
[tool.hatch.envs.default]
dependencies = ["coverage[toml]>=6.5", "pytest"]
[tool.hatch.envs.default.scripts]
test = "pytest {args:tests}"
test-cov = "coverage run -m pytest {args:tests}"
cov-report = ["- coverage combine", "coverage report"]
cov = ["test-cov", "cov-report"]
[[tool.hatch.envs.all.matrix]]
python = ["3.8", "3.9", "3.10", "3.11", "3.12"]
[tool.hatch.envs.types]
dependencies = ["mypy>=1.0.0"]
[tool.hatch.envs.types.scripts]
check = "mypy --install-types --non-interactive {args:play_outside tests}"
[tool.coverage.run]
source_pkgs = ["play_outside", "tests"]
branch = true
parallel = true
omit = ["play_outside/__about__.py"]
[tool.coverage.paths]
play_outside = ["play_outside", "*/play-outside/play_outside"]
tests = ["tests", "*/play-outside/tests"]
[tool.coverage.report]
exclude_lines = ["no cov", "if __name__ == .__main__.:", "if TYPE_CHECKING:"]

822
static/app.css Normal file
View file

@ -0,0 +1,822 @@
/*
! tailwindcss v3.4.1 | MIT License | https://tailwindcss.com
*/
/*
1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)
2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116)
*/
*,
::before,
::after {
box-sizing: border-box;
/* 1 */
border-width: 0;
/* 2 */
border-style: solid;
/* 2 */
border-color: #e5e7eb;
/* 2 */
}
::before,
::after {
--tw-content: '';
}
/*
1. Use a consistent sensible line-height in all browsers.
2. Prevent adjustments of font size after orientation changes in iOS.
3. Use a more readable tab size.
4. Use the user's configured `sans` font-family by default.
5. Use the user's configured `sans` font-feature-settings by default.
6. Use the user's configured `sans` font-variation-settings by default.
7. Disable tap highlights on iOS
*/
html,
:host {
line-height: 1.5;
/* 1 */
-webkit-text-size-adjust: 100%;
/* 2 */
-moz-tab-size: 4;
/* 3 */
-o-tab-size: 4;
tab-size: 4;
/* 3 */
font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
/* 4 */
font-feature-settings: normal;
/* 5 */
font-variation-settings: normal;
/* 6 */
-webkit-tap-highlight-color: transparent;
/* 7 */
}
/*
1. Remove the margin in all browsers.
2. Inherit line-height from `html` so users can set them as a class directly on the `html` element.
*/
body {
margin: 0;
/* 1 */
line-height: inherit;
/* 2 */
}
/*
1. Add the correct height in Firefox.
2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)
3. Ensure horizontal rules are visible by default.
*/
hr {
height: 0;
/* 1 */
color: inherit;
/* 2 */
border-top-width: 1px;
/* 3 */
}
/*
Add the correct text decoration in Chrome, Edge, and Safari.
*/
abbr:where([title]) {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
}
/*
Remove the default font size and weight for headings.
*/
h1,
h2,
h3,
h4,
h5,
h6 {
font-size: inherit;
font-weight: inherit;
}
/*
Reset links to optimize for opt-in styling instead of opt-out.
*/
a {
color: inherit;
text-decoration: inherit;
}
/*
Add the correct font weight in Edge and Safari.
*/
b,
strong {
font-weight: bolder;
}
/*
1. Use the user's configured `mono` font-family by default.
2. Use the user's configured `mono` font-feature-settings by default.
3. Use the user's configured `mono` font-variation-settings by default.
4. Correct the odd `em` font sizing in all browsers.
*/
code,
kbd,
samp,
pre {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
/* 1 */
font-feature-settings: normal;
/* 2 */
font-variation-settings: normal;
/* 3 */
font-size: 1em;
/* 4 */
}
/*
Add the correct font size in all browsers.
*/
small {
font-size: 80%;
}
/*
Prevent `sub` and `sup` elements from affecting the line height in all browsers.
*/
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
/*
1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)
2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)
3. Remove gaps between table borders by default.
*/
table {
text-indent: 0;
/* 1 */
border-color: inherit;
/* 2 */
border-collapse: collapse;
/* 3 */
}
/*
1. Change the font styles in all browsers.
2. Remove the margin in Firefox and Safari.
3. Remove default padding in all browsers.
*/
button,
input,
optgroup,
select,
textarea {
font-family: inherit;
/* 1 */
font-feature-settings: inherit;
/* 1 */
font-variation-settings: inherit;
/* 1 */
font-size: 100%;
/* 1 */
font-weight: inherit;
/* 1 */
line-height: inherit;
/* 1 */
color: inherit;
/* 1 */
margin: 0;
/* 2 */
padding: 0;
/* 3 */
}
/*
Remove the inheritance of text transform in Edge and Firefox.
*/
button,
select {
text-transform: none;
}
/*
1. Correct the inability to style clickable types in iOS and Safari.
2. Remove default button styles.
*/
button,
[type='button'],
[type='reset'],
[type='submit'] {
-webkit-appearance: button;
/* 1 */
background-color: transparent;
/* 2 */
background-image: none;
/* 2 */
}
/*
Use the modern Firefox focus style for all focusable elements.
*/
:-moz-focusring {
outline: auto;
}
/*
Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)
*/
:-moz-ui-invalid {
box-shadow: none;
}
/*
Add the correct vertical alignment in Chrome and Firefox.
*/
progress {
vertical-align: baseline;
}
/*
Correct the cursor style of increment and decrement buttons in Safari.
*/
::-webkit-inner-spin-button,
::-webkit-outer-spin-button {
height: auto;
}
/*
1. Correct the odd appearance in Chrome and Safari.
2. Correct the outline style in Safari.
*/
[type='search'] {
-webkit-appearance: textfield;
/* 1 */
outline-offset: -2px;
/* 2 */
}
/*
Remove the inner padding in Chrome and Safari on macOS.
*/
::-webkit-search-decoration {
-webkit-appearance: none;
}
/*
1. Correct the inability to style clickable types in iOS and Safari.
2. Change font properties to `inherit` in Safari.
*/
::-webkit-file-upload-button {
-webkit-appearance: button;
/* 1 */
font: inherit;
/* 2 */
}
/*
Add the correct display in Chrome and Safari.
*/
summary {
display: list-item;
}
/*
Removes the default spacing and border for appropriate elements.
*/
blockquote,
dl,
dd,
h1,
h2,
h3,
h4,
h5,
h6,
hr,
figure,
p,
pre {
margin: 0;
}
fieldset {
margin: 0;
padding: 0;
}
legend {
padding: 0;
}
ol,
ul,
menu {
list-style: none;
margin: 0;
padding: 0;
}
/*
Reset default styling for dialogs.
*/
dialog {
padding: 0;
}
/*
Prevent resizing textareas horizontally by default.
*/
textarea {
resize: vertical;
}
/*
1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)
2. Set the default placeholder color to the user's configured gray 400 color.
*/
input::-moz-placeholder, textarea::-moz-placeholder {
opacity: 1;
/* 1 */
color: #9ca3af;
/* 2 */
}
input::placeholder,
textarea::placeholder {
opacity: 1;
/* 1 */
color: #9ca3af;
/* 2 */
}
/*
Set the default cursor for buttons.
*/
button,
[role="button"] {
cursor: pointer;
}
/*
Make sure disabled buttons don't get the pointer cursor.
*/
:disabled {
cursor: default;
}
/*
1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14)
2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)
This can trigger a poorly considered lint error in some tools but is included by design.
*/
img,
svg,
video,
canvas,
audio,
iframe,
embed,
object {
display: block;
/* 1 */
vertical-align: middle;
/* 2 */
}
/*
Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)
*/
img,
video {
max-width: 100%;
height: auto;
}
/* Make elements with the HTML hidden attribute stay hidden by default */
[hidden] {
display: none;
}
*, ::before, ::after {
--tw-border-spacing-x: 0;
--tw-border-spacing-y: 0;
--tw-translate-x: 0;
--tw-translate-y: 0;
--tw-rotate: 0;
--tw-skew-x: 0;
--tw-skew-y: 0;
--tw-scale-x: 1;
--tw-scale-y: 1;
--tw-pan-x: ;
--tw-pan-y: ;
--tw-pinch-zoom: ;
--tw-scroll-snap-strictness: proximity;
--tw-gradient-from-position: ;
--tw-gradient-via-position: ;
--tw-gradient-to-position: ;
--tw-ordinal: ;
--tw-slashed-zero: ;
--tw-numeric-figure: ;
--tw-numeric-spacing: ;
--tw-numeric-fraction: ;
--tw-ring-inset: ;
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-color: rgb(59 130 246 / 0.5);
--tw-ring-offset-shadow: 0 0 #0000;
--tw-ring-shadow: 0 0 #0000;
--tw-shadow: 0 0 #0000;
--tw-shadow-colored: 0 0 #0000;
--tw-blur: ;
--tw-brightness: ;
--tw-contrast: ;
--tw-grayscale: ;
--tw-hue-rotate: ;
--tw-invert: ;
--tw-saturate: ;
--tw-sepia: ;
--tw-drop-shadow: ;
--tw-backdrop-blur: ;
--tw-backdrop-brightness: ;
--tw-backdrop-contrast: ;
--tw-backdrop-grayscale: ;
--tw-backdrop-hue-rotate: ;
--tw-backdrop-invert: ;
--tw-backdrop-opacity: ;
--tw-backdrop-saturate: ;
--tw-backdrop-sepia: ;
}
::backdrop {
--tw-border-spacing-x: 0;
--tw-border-spacing-y: 0;
--tw-translate-x: 0;
--tw-translate-y: 0;
--tw-rotate: 0;
--tw-skew-x: 0;
--tw-skew-y: 0;
--tw-scale-x: 1;
--tw-scale-y: 1;
--tw-pan-x: ;
--tw-pan-y: ;
--tw-pinch-zoom: ;
--tw-scroll-snap-strictness: proximity;
--tw-gradient-from-position: ;
--tw-gradient-via-position: ;
--tw-gradient-to-position: ;
--tw-ordinal: ;
--tw-slashed-zero: ;
--tw-numeric-figure: ;
--tw-numeric-spacing: ;
--tw-numeric-fraction: ;
--tw-ring-inset: ;
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-color: rgb(59 130 246 / 0.5);
--tw-ring-offset-shadow: 0 0 #0000;
--tw-ring-shadow: 0 0 #0000;
--tw-shadow: 0 0 #0000;
--tw-shadow-colored: 0 0 #0000;
--tw-blur: ;
--tw-brightness: ;
--tw-contrast: ;
--tw-grayscale: ;
--tw-hue-rotate: ;
--tw-invert: ;
--tw-saturate: ;
--tw-sepia: ;
--tw-drop-shadow: ;
--tw-backdrop-blur: ;
--tw-backdrop-brightness: ;
--tw-backdrop-contrast: ;
--tw-backdrop-grayscale: ;
--tw-backdrop-hue-rotate: ;
--tw-backdrop-invert: ;
--tw-backdrop-opacity: ;
--tw-backdrop-saturate: ;
--tw-backdrop-sepia: ;
}
.container {
width: 100%;
}
@media (min-width: 640px) {
.container {
max-width: 640px;
}
}
@media (min-width: 768px) {
.container {
max-width: 768px;
}
}
@media (min-width: 1024px) {
.container {
max-width: 1024px;
}
}
@media (min-width: 1280px) {
.container {
max-width: 1280px;
}
}
@media (min-width: 1536px) {
.container {
max-width: 1536px;
}
}
.mx-auto {
margin-left: auto;
margin-right: auto;
}
.my-4 {
margin-top: 1rem;
margin-bottom: 1rem;
}
.my-\[-1rem\] {
margin-top: -1rem;
margin-bottom: -1rem;
}
.my-\[-4rem\] {
margin-top: -4rem;
margin-bottom: -4rem;
}
.mb-0 {
margin-bottom: 0px;
}
.mt-24 {
margin-top: 6rem;
}
.ml-\[10rem\] {
margin-left: 10rem;
}
.block {
display: block;
}
.inline-block {
display: inline-block;
}
.max-w-3xl {
max-width: 48rem;
}
.max-w-4xl {
max-width: 56rem;
}
.list-none {
list-style-type: none;
}
.rounded-3xl {
border-radius: 1.5rem;
}
.border-2 {
border-width: 2px;
}
.border-green-500 {
--tw-border-opacity: 1;
border-color: rgb(34 197 94 / var(--tw-border-opacity));
}
.border-red-500 {
--tw-border-opacity: 1;
border-color: rgb(239 68 68 / var(--tw-border-opacity));
}
.border-yellow-500 {
--tw-border-opacity: 1;
border-color: rgb(234 179 8 / var(--tw-border-opacity));
}
.bg-green-500 {
--tw-bg-opacity: 1;
background-color: rgb(34 197 94 / var(--tw-bg-opacity));
}
.bg-red-500 {
--tw-bg-opacity: 1;
background-color: rgb(239 68 68 / var(--tw-bg-opacity));
}
.bg-yellow-500 {
--tw-bg-opacity: 1;
background-color: rgb(234 179 8 / var(--tw-bg-opacity));
}
.bg-zinc-900 {
--tw-bg-opacity: 1;
background-color: rgb(24 24 27 / var(--tw-bg-opacity));
}
.bg-opacity-10 {
--tw-bg-opacity: 0.1;
}
.bg-gradient-to-r {
background-image: linear-gradient(to right, var(--tw-gradient-stops));
}
.from-red-600 {
--tw-gradient-from: #dc2626 var(--tw-gradient-from-position);
--tw-gradient-to: rgb(220 38 38 / 0) var(--tw-gradient-to-position);
--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
}
.via-pink-500 {
--tw-gradient-to: rgb(236 72 153 / 0) var(--tw-gradient-to-position);
--tw-gradient-stops: var(--tw-gradient-from), #ec4899 var(--tw-gradient-via-position), var(--tw-gradient-to);
}
.to-yellow-400 {
--tw-gradient-to: #facc15 var(--tw-gradient-to-position);
}
.bg-clip-text {
-webkit-background-clip: text;
background-clip: text;
}
.p-4 {
padding: 1rem;
}
.pb-0 {
padding-bottom: 0px;
}
.text-center {
text-align: center;
}
.text-3xl {
font-size: 1.875rem;
line-height: 2.25rem;
}
.text-8xl {
font-size: 6rem;
line-height: 1;
}
.text-xl {
font-size: 1.25rem;
line-height: 1.75rem;
}
.font-black {
font-weight: 900;
}
.leading-loose {
line-height: 2;
}
.leading-tight {
line-height: 1.25;
}
.text-transparent {
color: transparent;
}
.text-zinc-100 {
--tw-text-opacity: 1;
color: rgb(244 244 245 / var(--tw-text-opacity));
}
.ring-red-500 {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(239 68 68 / var(--tw-ring-opacity));
}
html {
scroll-behavior: smooth;
--tw-bg-opacity: 1;
background-color: rgb(39 39 42 / var(--tw-bg-opacity));
--tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity));
}
html:-webkit-autofill {
--tw-bg-opacity: 1;
background-color: rgb(234 179 8 / var(--tw-bg-opacity));
}
html:autofill {
--tw-bg-opacity: 1;
background-color: rgb(234 179 8 / var(--tw-bg-opacity));
}
::-webkit-scrollbar {
height: 1rem;
width: 1rem;
}
::-webkit-scrollbar-track {
border-radius: 9999px;
--tw-bg-opacity: 1;
background-color: rgb(24 24 27 / var(--tw-bg-opacity));
}
body::-webkit-scrollbar-track {
border-radius: 9999px;
--tw-bg-opacity: 1;
background-color: rgb(219 39 119 / var(--tw-bg-opacity));
}
::-webkit-scrollbar-thumb {
border-radius: 9999px;
--tw-bg-opacity: 1;
background-color: rgb(82 82 91 / var(--tw-bg-opacity));
}
::-webkit-scrollbar-thumb:hover {
--tw-bg-opacity: 1;
background-color: rgb(113 113 122 / var(--tw-bg-opacity));
}
body::-webkit-scrollbar-thumb {
border-radius: 9999px;
--tw-bg-opacity: 1;
background-color: rgb(6 182 212 / var(--tw-bg-opacity));
}
body::-webkit-scrollbar-thumb:hover {
--tw-bg-opacity: 1;
background-color: rgb(34 211 238 / var(--tw-bg-opacity));
}
.autofill\:bg-yellow-500:-webkit-autofill {
--tw-bg-opacity: 1;
background-color: rgb(234 179 8 / var(--tw-bg-opacity));
}
.autofill\:bg-yellow-500:autofill {
--tw-bg-opacity: 1;
background-color: rgb(234 179 8 / var(--tw-bg-opacity));
}

BIN
static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

1
static/htmx.org@1.9.8 Normal file

File diff suppressed because one or more lines are too long

17
tailwind.config.js Normal file
View file

@ -0,0 +1,17 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["templates/**/*.html"],
plugins: [require("@tailwindcss/typography")],
theme: {
extend: {
width: {
128: "32rem",
},
boxShadow: {
xlc: "0 0 60px 15px rgba(0, 0, 0, 0.3)",
lgc: "0 0 20px 0px rgba(0, 0, 0, 0.3)",
},
},
},
};

28
tailwind/app.css Normal file
View file

@ -0,0 +1,28 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html {
scroll-behavior: smooth;
@apply bg-zinc-800 text-white autofill:bg-yellow-500;
}
::-webkit-scrollbar {
@apply h-4 w-4;
}
::-webkit-scrollbar-track {
@apply rounded-full bg-zinc-900;
}
body::-webkit-scrollbar-track {
@apply rounded-full bg-pink-600;
}
::-webkit-scrollbar-thumb {
@apply rounded-full bg-zinc-600 hover:bg-zinc-500;
}
body::-webkit-scrollbar-thumb {
@apply rounded-full bg-cyan-500 hover:bg-cyan-400;
}

49
templates/base.html Normal file
View file

@ -0,0 +1,49 @@
<!DOCTYPE html>
<html lang="en">
<head>
{% block head %}
<title>
{% block title %}Play Outside{% endblock %}
</title>
<link rel="icon" type="image/x-icon" href="{{ url_for('get_favicon') }}" />
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="og:title" name="og:title" content="Can I Play Outside" />
<meta name="twitter:title" name="twitter:title" content="Can I Play Outside" />
<meta name="twitter:card" content="summary_large_image">
<meta name="og:image" name="og:image"
content="https://shots.wayl.one/shot/?url=https://play-outside.wayl.one&height=1080&width=1920&scaled_width=1200&scaled_height=600&selectors=" />
<meta name="twitter:image" name="twitter:image"
content="https://shots.wayl.one/shot/?url=https://play-outside.wayl.one&height=1080&width=1920&scaled_width=1280&scaled_height=640&selectors=" />
<meta name="og:image:height" content="640" />
<meta name="og:image:width" content="1280" />
<meta name="og:url" name="og:url" content="https://waylonwalker.com" />
<meta name="description" name="description"
content="Check if my kids can play outside" />
<meta name="og:description" name="Check if my kids can play outside"
content="Check if my kids can play outside" />
<meta name="twitter:description" name="twitter:description"
content="Check if my kids can play outside" />
<link href="{{ url_for('get_app_css') }}" rel="stylesheet" />
<!-- <script src="{{ url_for('get_htmx') }}"></script> -->
{% if config.env == 'local' %}
{{ hot_reload.script(url_for('hot-reload') ) | safe }}
{% endif %}
{% endblock %}
</head>
<body >
<div class='p-4 bg-zinc-900 text-zinc-100 autofill:bg-yellow-500'>
<h2 class='mx-auto max-w-4xl'>
Disclaimer
</h2>
<p class='mx-auto max-w-4xl'>
I use this site to determine if my kids can play outside, each kid is different, use your own discretion before allowing kids outside.
</p>
</div>
{% block body %}
{{ content | safe }}
{% endblock %}
</body>
</html>

70
templates/card.html Normal file
View file

@ -0,0 +1,70 @@
{% if datetime.fromtimestamp(weather.dt).hour == 6 %}
</ul>
<hr>
<h2 class='block mt-24 text-3xl font-black text-center'>
{{ datetime.fromtimestamp(weather.dt).strftime(format = '%A') }}
<br>
<span class='text-xl'>
{{ datetime.fromtimestamp(weather.dt).strftime(format = '%Y-%m-%d') }}
</span>
</h2>
<ul class='list-none'>
{% endif %}
<li <p class='text-center'>
{{ datetime.fromtimestamp(weather.dt).strftime(format = '%I:%M %p') }}
</p>
<div
class='container p-4 my-4 mx-auto max-w-3xl list-none rounded-3xl border-2 {{ weather.play_condition.color.replace("bg-", "border-") }} {{ weather.play_condition.color }} bg-opacity-10'>
<h1 id="title"
class="inline-block pb-0 mx-auto mb-0 text-xl text-8xl font-black leading-tight leading-loose text-transparent bg-clip-text bg-gradient-to-r from-red-600 via-pink-500 to-yellow-400 ring-red-500 text-shadow-xl text-shadow-zinc-950 ring-5">
{{ weather.play_condition.message.replace('.', '<br />') | safe }}
</h1>
<p>
{{ weather.name }}
</p>
<p>
Feels Like: {{ weather.main.feels_like }}
</p>
<ul class='list-none'>
{% for w in weather.weather %}
<li>
<p>
{{ w.description }}
<img src='https://openweathermap.org/img/wn/{{ w.icon }}.png' class='inline-block my-[-1rem]' />
</p>
</li>
{% endfor %}
</ul>
<p>
Air Quality: {{ air_quality.list.0.main.aqi }}
</p>
<p>
Sunset: {{ datetime.fromtimestamp(sunset) }}
</p>
{% if weather.sys.sunset %}
{% if (datetime.fromtimestamp(weather.sys.sunset) - datetime.fromtimestamp(weather.dt)).total_seconds() > 0
%}
<p>
Time till sunset: {{ datetime.fromtimestamp(weather.sys.sunset) - datetime.fromtimestamp(weather.dt) }}
</p>
{% else %}
<p>
Its after sunset
</p>
{% endif %}
{% endif %}
<p>
Visibility: {{ weather.visibility }}
</p>
{% if weather.wind.speed > 5 %}
<p>
Wind Speed: {{ weather.wind.speed }}
</p>
{% endif %}
</div>
</li>

12
templates/card.txt Normal file
View file

@ -0,0 +1,12 @@
{% set w = int((len(weather.play_condition.message) - len(datetime.fromtimestamp(weather.dt).strftime(format = '%A %I:%M %p')))-1) %}
╭─ {{ datetime.fromtimestamp(weather.dt).strftime(format = '%A %I:%M %p')}} {{ '─'*w }}╮
│ {%if color %}{{ weather.play_condition.ansi_color }}{%endif%}{{ weather.play_condition.message | safe }}{%if color%}\033[0m{%endif%} │
╰─{{ '─'* len(weather.play_condition.message) }}─╯
{{ weather.name }}
Feels Like: {{ weather.main.feels_like }}
Air Quality: {{ air_quality.list.0.main.aqi }}
Sunset: {{ datetime.fromtimestamp(sunset).strftime(format = '%I:%M %p') }}
Visibility: {{ weather.visibility }}
{% if weather.wind.speed > 5 -%}
Wind Speed: {{ weather.wind.speed }}
{% endif -%}

View file

@ -0,0 +1,6 @@
<div class='bg-green-500 border-green-500'>
</div>
<div class='bg-red-500 border-red-500'>
</div>
<div class='bg-yellow-500 border-yellow-500'>
</div>

13
templates/index.html Normal file
View file

@ -0,0 +1,13 @@
{% extends "base.html" %}
{% block body %}
<h2 class='block mt-24 text-3xl font-black text-center'>Currently</h2>
<ul class='list-none'>
{% include "card.html" %}
</ul>
<h2 class='block mt-24 text-3xl font-black text-center'>5 Day Forecast</h2>
<ul class='list-none'>
{% for weather in forecast %}
{% include "card.html" %}
{% endfor %}
</ul>
{% endblock %}

4
templates/index.txt Normal file
View file

@ -0,0 +1,4 @@
{% include "card.txt" %}
{% for weather in forecast %}
{% include "card.txt" %}
{% endfor %}

3
tests/__init__.py Normal file
View file

@ -0,0 +1,3 @@
# SPDX-FileCopyrightText: 2024-present Waylon S. Walker <waylon@waylonwalker.com>
#
# SPDX-License-Identifier: MIT