aboutsummaryrefslogtreecommitdiffstats
path: root/app
diff options
context:
space:
mode:
authorMitsuo Tokumori <[email protected]>2025-03-08 16:03:30 +0900
committerMitsuo Tokumori <[email protected]>2025-03-08 16:03:30 +0900
commit51163b167cce01af6101438e5e61145ad798f213 (patch)
tree9c8e75266cedfb205db175b0b2bc41b49df75cea /app
parentd9af103b9a8aed86d6ac834f1240edfb2173ffa0 (diff)
downloadmasu-51163b167cce01af6101438e5e61145ad798f213.tar.gz
masu-51163b167cce01af6101438e5e61145ad798f213.tar.bz2
masu-51163b167cce01af6101438e5e61145ad798f213.zip
Restructure python code to be modular
The python code is now a package named app. app/models: db models app/routes: flask blueprints app/static: css, js app/templates: jinja html templates
Diffstat (limited to 'app')
-rw-r--r--app/__init__.py8
-rw-r--r--app/config.def.py1
-rw-r--r--app/routes/main.py43
-rw-r--r--app/static/block_time.js49
-rw-r--r--app/static/block_weather.js14
-rw-r--r--app/static/script.js50
-rw-r--r--app/static/style.css100
-rw-r--r--app/templates/index.html27
-rw-r--r--app/utils/block_weather.py66
9 files changed, 358 insertions, 0 deletions
diff --git a/app/__init__.py b/app/__init__.py
new file mode 100644
index 0000000..4bf2b5a
--- /dev/null
+++ b/app/__init__.py
@@ -0,0 +1,8 @@
+from flask import Flask
+from .routes import main
+
+def create_app():
+ app = Flask(__name__)
+ app.config.from_pyfile('config.py')
+ app.register_blueprint(routes.main.bp)
+ return app
diff --git a/app/config.def.py b/app/config.def.py
new file mode 100644
index 0000000..c80065d
--- /dev/null
+++ b/app/config.def.py
@@ -0,0 +1 @@
+OPENWEATHER_API_KEY = ""
diff --git a/app/routes/main.py b/app/routes/main.py
new file mode 100644
index 0000000..842a09b
--- /dev/null
+++ b/app/routes/main.py
@@ -0,0 +1,43 @@
+import flask
+import requests
+import datetime
+import zoneinfo
+import locale
+
+from ..utils import block_weather
+
+locale.setlocale(locale.LC_ALL, '')
+bp = flask.Blueprint('main', __name__)
+
+
+def index():
+ return flask.render_template("index.html")
+
+
+def get_time():
+ # TODO: date should be passed as JSON
+ date = datetime.datetime.now(zoneinfo.ZoneInfo("Asia/Tokyo"))
+ quarter = (date.month - 1) // 3 + 1
+ week = date.isocalendar().week
+ lines = [
+ "Tokyo: " + date.strftime("%x (%a)") + f" (Q{quarter}W{week})",
+ "Tokyo: " + date.astimezone(zoneinfo.ZoneInfo("Asia/Tokyo")).strftime("%H:%M (%Z) (UTC%:z)"),
+ "Berlin: " + date.astimezone(zoneinfo.ZoneInfo("Europe/Berlin")).strftime("%H:%M (%Z) (UTC%:z)"),
+ "Lima: " + date.astimezone(zoneinfo.ZoneInfo("America/Lima")).strftime("%H:%M (%Z) (UTC%:z)"),
+ "New York: " + date.astimezone(zoneinfo.ZoneInfo("America/New_York")).strftime("%H:%M (%Z) (UTC%:z)"),
+ "Chicago: " + date.astimezone(zoneinfo.ZoneInfo("America/Chicago")).strftime("%H:%M (%Z) (UTC%:z)"),
+ "Denver: " + date.astimezone(zoneinfo.ZoneInfo("America/Denver")).strftime("%H:%M (%Z) (UTC%:z)"),
+ "Los Angeles: " + date.astimezone(zoneinfo.ZoneInfo("America/Los_Angeles")).strftime("%H:%M (%Z) (UTC%:z)"),
+ "Anchorage: " + date.astimezone(zoneinfo.ZoneInfo("America/Anchorage")).strftime("%H:%M (%Z) (UTC%:z)"),
+ "Honolulu: " + date.astimezone(zoneinfo.ZoneInfo("Pacific/Honolulu")).strftime("%H:%M (%Z) (UTC%:z)"),
+ ]
+ return flask.jsonify({"time": "\n".join(lines)})
+
+
[email protected]("/weather")
+def get_weather():
+ city = flask.request.args.get("city", "Kofu, JP")
+ data = block_weather.get_current_weather(city)
+ return flask.jsonify(data)
diff --git a/app/static/block_time.js b/app/static/block_time.js
new file mode 100644
index 0000000..d6510ae
--- /dev/null
+++ b/app/static/block_time.js
@@ -0,0 +1,49 @@
+function init() {
+ initTimeVisualizer('timeVisualizer');
+}
+
+export function updateTime() {
+ fetch("/time")
+ .then(res => res.json())
+ .then(data => {
+ document.getElementById("weatherSummary").innerText = data.time;
+ });
+}
+
+function initTimeVisualizer(containerId) {
+ const grid = document.createElement('div');
+ grid.className = 'grid';
+ document.getElementById(containerId).appendChild(grid);
+
+ function updateTime() {
+ grid.innerHTML = '';
+ const now = new Date();
+ const hours = now.getHours();
+ const minutes = now.getMinutes();
+
+ for (let i = 0; i < 24; i++) {
+ const cell = document.createElement('div');
+ cell.className = 'cell';
+
+ if (i >= 20 || i < 4) cell.classList.add('sleep');
+ if (i < hours) cell.classList.add('past');
+
+ if (i === hours) {
+ cell.classList.add('current');
+ const fillPercentage = (minutes / 60) * 100;
+ cell.style.setProperty('--fill', `${fillPercentage}%`);
+ const line = document.createElement('div');
+ line.className = 'timeline';
+ line.style.left = `calc(${fillPercentage}% - 1px)`;
+ cell.appendChild(line);
+ }
+
+ grid.appendChild(cell);
+ }
+ }
+
+ updateTime();
+ setInterval(updateTime, 60000); // 1min
+}
+
+init()
diff --git a/app/static/block_weather.js b/app/static/block_weather.js
new file mode 100644
index 0000000..4ae6d2d
--- /dev/null
+++ b/app/static/block_weather.js
@@ -0,0 +1,14 @@
+export function updateWeather() {
+ const city = document.getElementById("city").value;
+ fetch(`/weather?city=${encodeURIComponent(city)}`)
+ .then(res => res.json())
+ .then(data => {
+ if (!data) {
+ document.getElementById("weather-summary").innerText = `Error, "${city}" city not found`;
+ return
+ }
+ document.getElementById("weather-summary").innerText = data.summary;
+ document.getElementById("weather-icon").src = data.icon_url;
+ });
+}
+
diff --git a/app/static/script.js b/app/static/script.js
new file mode 100644
index 0000000..efd6836
--- /dev/null
+++ b/app/static/script.js
@@ -0,0 +1,50 @@
+import { updateTime } from './block_time.js';
+import { updateWeather } from './block_weather.js';
+
+const config = {
+ polling: true
+}
+
+function init() {
+ const blocks = {
+ time: { interval: 30 * 1000, lastUpdate: 0, update: updateTime }, // 30s
+ weather: { interval: 30 * 60000, lastUpdate: 0, update: updateWeather } // 30min
+ // Add more: { interval: X, lastUpdate: 0, update: updateFunction }
+ };
+
+ initHeaderControls(blocks)
+
+ // Initial load
+ // Poll every 500ms to check intervals (fast enough for 1s updates, light on CPU)
+ // maybe the 1s updates should be special case, but for now let's keep it simple
+ reloadAll(blocks);
+ setInterval(() => pollUpdates(blocks), 500);
+}
+
+function reloadAll(blocks) {
+ Object.keys(blocks).forEach(key => {
+ blocks[key].update();
+ blocks[key].lastUpdate = Date.now();
+ });
+}
+
+function pollUpdates(blocks) {
+ if (!config.polling) return;
+ const now = Date.now();
+ Object.keys(blocks).forEach(key => {
+ const block = blocks[key];
+ if (now - block.lastUpdate >= block.interval) {
+ block.update();
+ block.lastUpdate = now;
+ }
+ });
+}
+
+function initHeaderControls(blocks) {
+ document.getElementById("reload").addEventListener("click", () => reloadAll(blocks));
+ document.getElementById("pause").addEventListener("change", (e) => {
+ config.polling = e.target.checked;
+ });
+}
+
+init()
diff --git a/app/static/style.css b/app/static/style.css
new file mode 100644
index 0000000..a186f24
--- /dev/null
+++ b/app/static/style.css
@@ -0,0 +1,100 @@
+/* MASU */
+body {
+ margin: 0;
+ padding: 0;
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+header {
+ width: 100%;
+ height: 80px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+
+header button, header label {
+ margin: 0 10px;
+}
+
+#container {
+ width: 100%;
+ /* max-width: 1920px; */
+ padding: 20px;
+ box-sizing: border-box;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 20px;
+ justify-content: center;
+}
+
+.block {
+ width: 420px;
+ height: 420px;
+ flex-shrink: 0;
+
+ padding: 5px;
+ border: 1px solid black;
+}
+
+@media (max-width: 440px) {
+ .grid {
+ padding: 20px 0;
+ }
+}
+
+/* block_time */
+#time .block {
+ justify-content: center;
+}
+
+#timeVisualizer .grid {
+ display: grid;
+ grid-template-columns: repeat(8, 42px);
+ grid-template-rows: repeat(3, 42px);
+ gap: 2px;
+ background: #f0f0f0;
+ padding: 5px;
+ width: fit-content;
+ margin: 0 auto;
+}
+
+#timeVisualizer .cell {
+ width: 42px;
+ height: 42px;
+ background: #ffffff;
+ position: relative;
+ border: 1px solid #ddd;
+}
+
+#timeVisualizer .sleep {
+ background: #e6f3ff;
+}
+
+#timeVisualizer .past {
+ background: #cccccc;
+}
+
+#timeVisualizer .current::after {
+ content: '';
+ position: absolute;
+ left: 0;
+ top: 0;
+ height: 100%;
+ width: var(--fill);
+ background: #cccccc;
+}
+
+#timeVisualizer .timeline {
+ position: absolute;
+ width: 2px;
+ height: 100%;
+ background: #000000;
+ z-index: 1;
+}
+
+/* block_weather */
diff --git a/app/templates/index.html b/app/templates/index.html
new file mode 100644
index 0000000..332b78b
--- /dev/null
+++ b/app/templates/index.html
@@ -0,0 +1,27 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <title>MASU</title>
+ <link rel="stylesheet" href="/static/style.css">
+</head>
+<body>
+ <header>
+ <button id="reload">Reload</button>
+ <label>Weather City: <input id="city" value="Kofu, JP"></label>
+ <label><input type="checkbox" id="pause" checked> Auto-Update</label>
+ </header>
+ <div id="container">
+ <!-- TODO: Use Jinja2 blocks -->
+ <div class="block" id="time">
+ <p id="weatherSummary"></p>
+ <div id="timeVisualizer"></div>
+ </div>
+ <div class="block" id="weather">
+ <img id="weather-icon" src="" alt="weather icon">
+ <p id="weather-summary"></p>
+ </div>
+ <!-- Add more blocks -->
+ </div>
+ <script type="module" src="/static/script.js"></script>
+</body>
+</html>
diff --git a/app/utils/block_weather.py b/app/utils/block_weather.py
new file mode 100644
index 0000000..eec4e4a
--- /dev/null
+++ b/app/utils/block_weather.py
@@ -0,0 +1,66 @@
+"""
+Handle external requests and pre-process weather data
+
+Providers:
+* OpenWeather
+ Free Access on OpenWeather allows for some API calls, see
+ https://openweathermap.org/price
+"""
+
+import flask
+import requests
+import pandas as pd
+
+def get_current_weather(city: str) -> dict:
+ """https://openweathermap.org/current"""
+ pos = _get_position(city)
+ if not pos:
+ return None
+ url = f"http://api.openweathermap.org/data/2.5/weather?lat={pos['lat']}&lon={pos['lon']}&appid={flask.current_app.config['OPENWEATHER_API_KEY']}&units=metric"
+ data = requests.get(url).json()
+
+ data['summary'] = _format_current_weather(data)
+ # On icon URL codes: https://openweathermap.org/weather-conditions#Weather-Condition-Codes-2
+ data['icon_url'] = f"https://openweathermap.org/img/wn/{data['weather'][0]['icon']}.png"
+ return data
+
+
+def _format_current_weather(data: dict) -> str:
+ """final formatting should occur in HTML/JS, not here"""
+ ts = [data['dt'], data['sys']['sunrise'], data['sys']['sunset']]
+ ts = [pd.to_datetime(x + data['timezone'], unit='s') for x in ts]
+ s = f"""{data['name']}, {data['sys']['country']} ({ts[0]})
+{data['weather'][0]['description']}
+{data['main']['temp']}°C (feels like {data['main']['feels_like']}°C), humidity: {data['main']['humidity']}%, pressure: {data['main']['grnd_level']} hPa
+visibility: {data.get('visibility', 0)/1000:.1f}km
+wind: {data['wind']['speed']}m/s from {data['wind']['deg']}°N
+clouds: {data['clouds']['all']}%
+sunrise & sunset: {ts[1].strftime('%H:%M')}, {ts[2].strftime('%H:%M')}"""
+ return s
+
+
+def get_forecast(city: str) -> dict:
+ """https://openweathermap.org/forecast5"""
+ # 5 days, 3 hours forecast data
+ lat, lon = _get_position(city)
+ url = f"http://api.openweathermap.org/data/2.5/forecast?lat={lat}&lon={lon}&appid={flask.current_app.config['OPENWEATHER_API_KEY']}&units=metric"
+ data = requests.get(url).json()
+ return data
+
+
+def _get_position(name) -> dict:
+ """direct geocoding
+ name: City name, state code (only for the US) and country code divided by comma. Please use ISO 3166 country codes.
+
+ https://openweathermap.org/api/geocoding-api#direct
+ """
+ limit = 5
+ url = f"http://api.openweathermap.org/geo/1.0/direct?q={name}&limit={limit}&appid={flask.current_app.config['OPENWEATHER_API_KEY']}"
+ data = requests.get(url).json()
+ if not data:
+ return None
+ return data[0]
+
+
+def get_weathermap() -> dict:
+ """https://openweathermap.org/api/weathermaps"""