commit 0500266b92f08ed29f69b946805171c32196e06c
Author: Waylon S. Walker
Date: Wed May 8 20:45:33 2024 -0500
init
diff --git a/justfile b/justfile
new file mode 100644
index 0000000..48b27dc
--- /dev/null
+++ b/justfile
@@ -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
diff --git a/play_outside/__about__.py b/play_outside/__about__.py
new file mode 100644
index 0000000..f08d8c2
--- /dev/null
+++ b/play_outside/__about__.py
@@ -0,0 +1,4 @@
+# SPDX-FileCopyrightText: 2024-present Waylon S. Walker
+#
+# SPDX-License-Identifier: MIT
+__version__ = "0.0.28"
diff --git a/play_outside/__init__.py b/play_outside/__init__.py
new file mode 100644
index 0000000..a2123ed
--- /dev/null
+++ b/play_outside/__init__.py
@@ -0,0 +1,3 @@
+# SPDX-FileCopyrightText: 2024-present Waylon S. Walker
+#
+# SPDX-License-Identifier: MIT
diff --git a/play_outside/api.py b/play_outside/api.py
new file mode 100644
index 0000000..f6a461a
--- /dev/null
+++ b/play_outside/api.py
@@ -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)
diff --git a/play_outside/cli/api.py b/play_outside/cli/api.py
new file mode 100644
index 0000000..2906903
--- /dev/null
+++ b/play_outside/cli/api.py
@@ -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()
diff --git a/play_outside/config.py b/play_outside/config.py
new file mode 100644
index 0000000..93e11bd
--- /dev/null
+++ b/play_outside/config.py
@@ -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
diff --git a/play_outside/decorators.py b/play_outside/decorators.py
new file mode 100644
index 0000000..0da3e3f
--- /dev/null
+++ b/play_outside/decorators.py
@@ -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
diff --git a/play_outside/queries.py b/play_outside/queries.py
new file mode 100644
index 0000000..bdbcc12
--- /dev/null
+++ b/play_outside/queries.py
@@ -0,0 +1,4 @@
+import httpx
+
+
+# get weather from open weather api
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..4c9e9cf
--- /dev/null
+++ b/pyproject.toml
@@ -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:"]
diff --git a/static/app.css b/static/app.css
new file mode 100644
index 0000000..12a829e
--- /dev/null
+++ b/static/app.css
@@ -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));
+}
diff --git a/static/favicon.ico b/static/favicon.ico
new file mode 100644
index 0000000..566ebd9
Binary files /dev/null and b/static/favicon.ico differ
diff --git a/static/htmx.org@1.9.8 b/static/htmx.org@1.9.8
new file mode 100644
index 0000000..4091536
--- /dev/null
+++ b/static/htmx.org@1.9.8
@@ -0,0 +1 @@
+(function(e,t){if(typeof define==="function"&&define.amd){define([],t)}else if(typeof module==="object"&&module.exports){module.exports=t()}else{e.htmx=e.htmx||t()}})(typeof self!=="undefined"?self:this,function(){return function(){"use strict";var Y={onLoad:t,process:Dt,on:Z,off:K,trigger:fe,ajax:Cr,find:E,findAll:f,closest:d,values:function(e,t){var r=or(e,t||"post");return r.values},remove:B,addClass:F,removeClass:n,toggleClass:V,takeClass:j,defineExtension:Ar,removeExtension:Nr,logAll:X,logNone:U,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",useTemplateFragments:false,scrollBehavior:"smooth",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get"],selfRequestsOnly:false,scrollIntoViewOnBoost:true},parseInterval:v,_:e,createEventSource:function(e){return new EventSource(e,{withCredentials:true})},createWebSocket:function(e){var t=new WebSocket(e,[]);t.binaryType=Y.config.wsBinaryType;return t},version:"1.9.8"};var r={addTriggerHandler:St,bodyContains:oe,canAccessLocalStorage:M,findThisElement:ve,filterValues:cr,hasAttribute:o,getAttributeValue:ee,getClosestAttributeValue:re,getClosestMatch:c,getExpressionVars:wr,getHeaders:fr,getInputValues:or,getInternalData:ie,getSwapSpecification:dr,getTriggerSpecs:Ze,getTarget:ge,makeFragment:l,mergeObjects:se,makeSettleInfo:T,oobSwap:ye,querySelectorExt:le,selectAndSwap:Ue,settleImmediately:Jt,shouldCancel:tt,triggerEvent:fe,triggerErrorEvent:ue,withExtensions:C};var b=["get","post","put","delete","patch"];var w=b.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function v(e){if(e==undefined){return undefined}if(e.slice(-2)=="ms"){return parseFloat(e.slice(0,-2))||undefined}if(e.slice(-1)=="s"){return parseFloat(e.slice(0,-1))*1e3||undefined}if(e.slice(-1)=="m"){return parseFloat(e.slice(0,-1))*1e3*60||undefined}return parseFloat(e)||undefined}function Q(e,t){return e.getAttribute&&e.getAttribute(t)}function o(e,t){return e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function ee(e,t){return Q(e,t)||Q(e,"data-"+t)}function u(e){return e.parentElement}function te(){return document}function c(e,t){while(e&&!t(e)){e=u(e)}return e?e:null}function R(e,t,r){var n=ee(t,r);var i=ee(t,"hx-disinherit");if(e!==t&&i&&(i==="*"||i.split(" ").indexOf(r)>=0)){return"unset"}else{return n}}function re(t,r){var n=null;c(t,function(e){return n=R(t,e,r)});if(n!=="unset"){return n}}function h(e,t){var r=e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector;return r&&r.call(e,t)}function q(e){var t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;var r=t.exec(e);if(r){return r[1].toLowerCase()}else{return""}}function i(e,t){var r=new DOMParser;var n=r.parseFromString(e,"text/html");var i=n.body;while(t>0){t--;i=i.firstChild}if(i==null){i=te().createDocumentFragment()}return i}function H(e){return e.match(/"+e+"",0);return r.querySelector("template").content}else{var n=q(e);switch(n){case"thead":case"tbody":case"tfoot":case"colgroup":case"caption":return i("",1);case"col":return i("",2);case"tr":return i("",2);case"td":case"th":return i("",3);case"script":case"style":return i(""+e+"
",1);default:return i(e,0)}}}function ne(e){if(e){e()}}function L(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function A(e){return L(e,"Function")}function N(e){return L(e,"Object")}function ie(e){var t="htmx-internal-data";var r=e[t];if(!r){r=e[t]={}}return r}function I(e){var t=[];if(e){for(var r=0;r=0}function oe(e){if(e.getRootNode&&e.getRootNode()instanceof window.ShadowRoot){return te().body.contains(e.getRootNode().host)}else{return te().body.contains(e)}}function P(e){return e.trim().split(/\s+/)}function se(e,t){for(var r in t){if(t.hasOwnProperty(r)){e[r]=t[r]}}return e}function S(e){try{return JSON.parse(e)}catch(e){y(e);return null}}function M(){var e="htmx:localStorageTest";try{localStorage.setItem(e,e);localStorage.removeItem(e);return true}catch(e){return false}}function D(t){try{var e=new URL(t);if(e){t=e.pathname+e.search}if(!t.match("^/$")){t=t.replace(/\/+$/,"")}return t}catch(e){return t}}function e(e){return xr(te().body,function(){return eval(e)})}function t(t){var e=Y.on("htmx:load",function(e){t(e.detail.elt)});return e}function X(){Y.logger=function(e,t,r){if(console){console.log(t,e,r)}}}function U(){Y.logger=null}function E(e,t){if(t){return e.querySelector(t)}else{return E(te(),e)}}function f(e,t){if(t){return e.querySelectorAll(t)}else{return f(te(),e)}}function B(e,t){e=s(e);if(t){setTimeout(function(){B(e);e=null},t)}else{e.parentElement.removeChild(e)}}function F(e,t,r){e=s(e);if(r){setTimeout(function(){F(e,t);e=null},r)}else{e.classList&&e.classList.add(t)}}function n(e,t,r){e=s(e);if(r){setTimeout(function(){n(e,t);e=null},r)}else{if(e.classList){e.classList.remove(t);if(e.classList.length===0){e.removeAttribute("class")}}}}function V(e,t){e=s(e);e.classList.toggle(t)}function j(e,t){e=s(e);ae(e.parentElement.children,function(e){n(e,t)});F(e,t)}function d(e,t){e=s(e);if(e.closest){return e.closest(t)}else{do{if(e==null||h(e,t)){return e}}while(e=e&&u(e));return null}}function g(e,t){return e.substring(0,t.length)===t}function _(e,t){return e.substring(e.length-t.length)===t}function z(e){var t=e.trim();if(g(t,"<")&&_(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function W(e,t){if(t.indexOf("closest ")===0){return[d(e,z(t.substr(8)))]}else if(t.indexOf("find ")===0){return[E(e,z(t.substr(5)))]}else if(t==="next"){return[e.nextElementSibling]}else if(t.indexOf("next ")===0){return[$(e,z(t.substr(5)))]}else if(t==="previous"){return[e.previousElementSibling]}else if(t.indexOf("previous ")===0){return[G(e,z(t.substr(9)))]}else if(t==="document"){return[document]}else if(t==="window"){return[window]}else if(t==="body"){return[document.body]}else{return te().querySelectorAll(z(t))}}var $=function(e,t){var r=te().querySelectorAll(t);for(var n=0;n=0;n--){var i=r[n];if(i.compareDocumentPosition(e)===Node.DOCUMENT_POSITION_FOLLOWING){return i}}};function le(e,t){if(t){return W(e,t)[0]}else{return W(te().body,e)[0]}}function s(e){if(L(e,"String")){return E(e)}else{return e}}function J(e,t,r){if(A(t)){return{target:te().body,event:e,listener:t}}else{return{target:s(e),event:t,listener:r}}}function Z(t,r,n){Pr(function(){var e=J(t,r,n);e.target.addEventListener(e.event,e.listener)});var e=A(r);return e?r:n}function K(t,r,n){Pr(function(){var e=J(t,r,n);e.target.removeEventListener(e.event,e.listener)});return A(r)?r:n}var he=te().createElement("output");function de(e,t){var r=re(e,t);if(r){if(r==="this"){return[ve(e,t)]}else{var n=W(e,r);if(n.length===0){y('The selector "'+r+'" on '+t+" returned no matches!");return[he]}else{return n}}}}function ve(e,t){return c(e,function(e){return ee(e,t)!=null})}function ge(e){var t=re(e,"hx-target");if(t){if(t==="this"){return ve(e,"hx-target")}else{return le(e,t)}}else{var r=ie(e);if(r.boosted){return te().body}else{return e}}}function me(e){var t=Y.config.attributesToSettle;for(var r=0;r0){o=e.substr(0,e.indexOf(":"));t=e.substr(e.indexOf(":")+1,e.length)}else{o=e}var r=te().querySelectorAll(t);if(r){ae(r,function(e){var t;var r=i.cloneNode(true);t=te().createDocumentFragment();t.appendChild(r);if(!xe(o,e)){t=r}var n={shouldSwap:true,target:e,fragment:t};if(!fe(e,"htmx:oobBeforeSwap",n))return;e=n.target;if(n["shouldSwap"]){De(o,e,e,t,a)}ae(a.elts,function(e){fe(e,"htmx:oobAfterSwap",n)})});i.parentNode.removeChild(i)}else{i.parentNode.removeChild(i);ue(te().body,"htmx:oobErrorNoTarget",{content:i})}return e}function be(e,t,r){var n=re(e,"hx-select-oob");if(n){var i=n.split(",");for(let e=0;e0){var r=t.replace("'","\\'");var n=e.tagName.replace(":","\\:");var i=o.querySelector(n+"[id='"+r+"']");if(i&&i!==o){var a=e.cloneNode();pe(e,i);s.tasks.push(function(){pe(e,a)})}}})}function Ee(e){return function(){n(e,Y.config.addedClass);Dt(e);Ct(e);Ce(e);fe(e,"htmx:load")}}function Ce(e){var t="[autofocus]";var r=h(e,t)?e:e.querySelector(t);if(r!=null){r.focus()}}function a(e,t,r,n){Se(e,r,n);while(r.childNodes.length>0){var i=r.firstChild;F(i,Y.config.addedClass);e.insertBefore(i,t);if(i.nodeType!==Node.TEXT_NODE&&i.nodeType!==Node.COMMENT_NODE){n.tasks.push(Ee(i))}}}function Te(e,t){var r=0;while(r-1){var t=e.replace(/
+
+
+
+ {{ weather.play_condition.message.replace('.', '
') | safe }}
+
+
+
+ {{ weather.name }}
+
+
+ Feels Like: {{ weather.main.feels_like }}
+
+
+
+ Air Quality: {{ air_quality.list.0.main.aqi }}
+
+
+ Sunset: {{ datetime.fromtimestamp(sunset) }}
+
+ {% if weather.sys.sunset %}
+ {% if (datetime.fromtimestamp(weather.sys.sunset) - datetime.fromtimestamp(weather.dt)).total_seconds() > 0
+ %}
+
+
+ Time till sunset: {{ datetime.fromtimestamp(weather.sys.sunset) - datetime.fromtimestamp(weather.dt) }}
+
+ {% else %}
+
+ Its after sunset
+
+ {% endif %}
+ {% endif %}
+
+ Visibility: {{ weather.visibility }}
+
+ {% if weather.wind.speed > 5 %}
+
+ Wind Speed: {{ weather.wind.speed }}
+
+ {% endif %}
+
+
diff --git a/templates/card.txt b/templates/card.txt
new file mode 100644
index 0000000..559c3b1
--- /dev/null
+++ b/templates/card.txt
@@ -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 -%}
diff --git a/templates/includestyles.html b/templates/includestyles.html
new file mode 100644
index 0000000..345bfd7
--- /dev/null
+++ b/templates/includestyles.html
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/templates/index.html b/templates/index.html
new file mode 100644
index 0000000..ceb2a30
--- /dev/null
+++ b/templates/index.html
@@ -0,0 +1,13 @@
+{% extends "base.html" %}
+{% block body %}
+ Currently
+
+ {% include "card.html" %}
+
+ 5 Day Forecast
+
+ {% for weather in forecast %}
+ {% include "card.html" %}
+ {% endfor %}
+
+{% endblock %}
diff --git a/templates/index.txt b/templates/index.txt
new file mode 100644
index 0000000..8e009e0
--- /dev/null
+++ b/templates/index.txt
@@ -0,0 +1,4 @@
+{% include "card.txt" %}
+{% for weather in forecast %}
+ {% include "card.txt" %}
+{% endfor %}
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..a2123ed
--- /dev/null
+++ b/tests/__init__.py
@@ -0,0 +1,3 @@
+# SPDX-FileCopyrightText: 2024-present Waylon S. Walker
+#
+# SPDX-License-Identifier: MIT