commit a33f7a297d289df9507ffe0aa87f054decac459e Author: Codex Date: Sun Oct 4 19:27:36 2026 +0100 Initial shared Stardew Valley tracker diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d191c6f --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.pyc +*.sqlite3 +result +.direnv/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c4c083a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Fredrik Gregersen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..5b6c362 --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +# Valley Yearbook + +A small, shared tracker for the first two years of Stardew Valley. It combines: + +- the standard Community Center bundles; +- crop growth times and last safe planting dates; +- birthdays, festivals, seasonal events, and weekly reminders; and +- one server-side checklist that stays in sync across phones and computers. + +The app is deliberately dependency-free at runtime: Python's standard library serves the site and stores progress in SQLite. Nginx/Authelia are expected to provide the public access boundary. + +## Run locally + +```sh +python3 app.py --database /tmp/stardew-progress.sqlite3 +``` + +Then open `http://127.0.0.1:8089`. + +## NixOS module + +Add this repository as a flake input, import `nixosModules.default`, and enable: + +```nix +services.stardew-tracker.enable = true; +``` + +The service listens on loopback port `8089` by default and stores its SQLite database in `/var/lib/stardew-tracker`. + +When proxying through Nginx, protect the complete origin with Authelia and pass its authenticated username as `Remote-User`. Progress is shared by everyone who can access the site; the username is retained only as lightweight update metadata. + +## Data scope + +The planner targets Stardew Valley 1.6 and standard bundles, not remixed bundles. Calendar and bundle data is based on the [Stardew Valley Wiki](https://stardewvalleywiki.com/Calendar). + +Stardew Valley is © ConcernedApe. This is an unofficial fan-made utility and includes no game art or assets. diff --git a/app.py b/app.py new file mode 100644 index 0000000..eac1bfb --- /dev/null +++ b/app.py @@ -0,0 +1,231 @@ +#!/usr/bin/env python3 +"""Small, dependency-free server for the shared Stardew tracker.""" + +from __future__ import annotations + +import argparse +import json +import logging +import mimetypes +import re +import sqlite3 +from datetime import datetime, timezone +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import unquote, urlparse + + +APP_DIR = Path(__file__).resolve().parent +STATIC_DIR = APP_DIR / "static" +ITEM_ID = re.compile(r"^[a-z0-9][a-z0-9-]{0,79}$") +MAX_BODY = 2048 + + +def utc_now() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +class Store: + def __init__(self, path: str | Path): + self.path = str(path) + + def connect(self) -> sqlite3.Connection: + connection = sqlite3.connect(self.path, timeout=10) + connection.row_factory = sqlite3.Row + connection.execute("PRAGMA journal_mode=WAL") + connection.execute("PRAGMA busy_timeout=10000") + return connection + + def initialize(self) -> None: + with self.connect() as db: + db.executescript( + """ + CREATE TABLE IF NOT EXISTS checks ( + item_id TEXT PRIMARY KEY, + checked INTEGER NOT NULL CHECK (checked IN (0, 1)), + updated_at TEXT NOT NULL, + updated_by TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS metadata ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + INSERT OR IGNORE INTO metadata (key, value) VALUES ('revision', '0'); + """ + ) + + def snapshot(self) -> dict: + with self.connect() as db: + revision = int( + db.execute("SELECT value FROM metadata WHERE key = 'revision'").fetchone()[0] + ) + rows = db.execute( + "SELECT item_id, checked, updated_at, updated_by FROM checks ORDER BY item_id" + ).fetchall() + latest_row = db.execute( + "SELECT updated_at, updated_by FROM checks ORDER BY updated_at DESC, rowid DESC LIMIT 1" + ).fetchone() + return { + "revision": revision, + "updatedAt": latest_row["updated_at"] if latest_row else None, + "updatedBy": latest_row["updated_by"] if latest_row else None, + "checks": {row["item_id"]: bool(row["checked"]) for row in rows}, + } + + def set_check(self, item_id: str, checked: bool, user: str) -> dict: + changed_at = utc_now() + with self.connect() as db: + db.execute("BEGIN IMMEDIATE") + db.execute( + """ + INSERT INTO checks (item_id, checked, updated_at, updated_by) + VALUES (?, ?, ?, ?) + ON CONFLICT(item_id) DO UPDATE SET + checked = excluded.checked, + updated_at = excluded.updated_at, + updated_by = excluded.updated_by + """, + (item_id, int(checked), changed_at, user), + ) + db.execute( + "UPDATE metadata SET value = CAST(value AS INTEGER) + 1 WHERE key = 'revision'" + ) + revision = int( + db.execute("SELECT value FROM metadata WHERE key = 'revision'").fetchone()[0] + ) + return { + "itemId": item_id, + "checked": checked, + "revision": revision, + "updatedAt": changed_at, + "updatedBy": user, + } + + +class TrackerServer(ThreadingHTTPServer): + daemon_threads = True + + def __init__(self, address: tuple[str, int], store: Store): + super().__init__(address, Handler) + self.store = store + + +class Handler(BaseHTTPRequestHandler): + server: TrackerServer + + def log_message(self, format: str, *args: object) -> None: + logging.info("%s - %s", self.address_string(), format % args) + + def end_headers(self) -> None: + self.send_header("X-Content-Type-Options", "nosniff") + self.send_header("X-Frame-Options", "SAMEORIGIN") + self.send_header("Referrer-Policy", "no-referrer") + self.send_header("Permissions-Policy", "camera=(), microphone=(), geolocation=()") + super().end_headers() + + def json_response(self, payload: dict, status: HTTPStatus = HTTPStatus.OK) -> None: + body = json.dumps(payload, separators=(",", ":")).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + + def error_json(self, message: str, status: HTTPStatus) -> None: + self.json_response({"error": message}, status) + + def authenticated_user(self) -> str: + value = self.headers.get("Remote-User", "").strip() + return value[:120] or "authelia-user" + + def do_GET(self) -> None: + path = urlparse(self.path).path + if path == "/health": + self.json_response({"status": "ok"}) + return + if path == "/api/state": + self.json_response(self.server.store.snapshot()) + return + self.serve_static(path) + + def do_POST(self) -> None: + path = urlparse(self.path).path + if path != "/api/checks": + self.error_json("Not found", HTTPStatus.NOT_FOUND) + return + if self.headers.get_content_type() != "application/json": + self.error_json("Expected application/json", HTTPStatus.UNSUPPORTED_MEDIA_TYPE) + return + try: + length = int(self.headers.get("Content-Length", "0")) + except ValueError: + self.error_json("Invalid content length", HTTPStatus.BAD_REQUEST) + return + if length <= 0 or length > MAX_BODY: + self.error_json("Invalid request size", HTTPStatus.BAD_REQUEST) + return + try: + payload = json.loads(self.rfile.read(length)) + except (json.JSONDecodeError, UnicodeDecodeError): + self.error_json("Invalid JSON", HTTPStatus.BAD_REQUEST) + return + item_id = payload.get("itemId") if isinstance(payload, dict) else None + checked = payload.get("checked") if isinstance(payload, dict) else None + if not isinstance(item_id, str) or not ITEM_ID.fullmatch(item_id): + self.error_json("Invalid itemId", HTTPStatus.BAD_REQUEST) + return + if not isinstance(checked, bool): + self.error_json("checked must be boolean", HTTPStatus.BAD_REQUEST) + return + result = self.server.store.set_check(item_id, checked, self.authenticated_user()) + self.json_response(result) + + def serve_static(self, request_path: str) -> None: + relative = "index.html" if request_path == "/" else unquote(request_path.lstrip("/")) + if not relative or ".." in Path(relative).parts: + self.send_error(HTTPStatus.NOT_FOUND) + return + target = (STATIC_DIR / relative).resolve() + try: + target.relative_to(STATIC_DIR.resolve()) + except ValueError: + self.send_error(HTTPStatus.NOT_FOUND) + return + if not target.is_file(): + self.send_error(HTTPStatus.NOT_FOUND) + return + body = target.read_bytes() + content_type = mimetypes.guess_type(target.name)[0] or "application/octet-stream" + self.send_response(HTTPStatus.OK) + textual = content_type.startswith("text/") or content_type in { + "application/javascript", + "application/json", + "image/svg+xml", + } + self.send_header("Content-Type", f"{content_type}; charset=utf-8" if textual else content_type) + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-cache" if target.name == "index.html" else "public, max-age=3600") + self.end_headers() + self.wfile.write(body) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Shared two-year Stardew Valley tracker") + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=8089) + parser.add_argument("--database", default="./progress.sqlite3") + args = parser.parse_args() + + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") + Path(args.database).parent.mkdir(parents=True, exist_ok=True) + store = Store(args.database) + store.initialize() + server = TrackerServer((args.host, args.port), store) + logging.info("Listening on http://%s:%s", args.host, args.port) + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..2377b28 --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1791031588, + "narHash": "sha256-/FGDr01siZ8txrVzkOz6CYjiz0XR27JuVJP+aWchi4s=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "825e2028c29b702a4a5f085f08095d12099784f2", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-26.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..b304734 --- /dev/null +++ b/flake.nix @@ -0,0 +1,37 @@ +{ + description = "Shared two-year Stardew Valley Community Center tracker"; + + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05"; + + outputs = { self, nixpkgs }: + let + forAllSystems = nixpkgs.lib.genAttrs [ "x86_64-linux" "aarch64-linux" ]; + in + { + packages = forAllSystems (system: + let pkgs = import nixpkgs { inherit system; }; + in { + default = pkgs.callPackage ./package.nix { }; + stardew-tracker = self.packages.${system}.default; + }); + + nixosModules.default = import ./module.nix self; + nixosModules.stardew-tracker = self.nixosModules.default; + + checks = forAllSystems (system: + let pkgs = import nixpkgs { inherit system; }; + in { + inherit (self.packages.${system}) default; + tests = pkgs.runCommand "stardew-tracker-tests" { nativeBuildInputs = [ pkgs.python3 pkgs.nodejs ]; } '' + cp -r ${self} source + chmod -R u+w source + cd source + python3 -m unittest discover -s tests -v + python3 -m py_compile app.py + node --check static/app.js + node --check static/data.js + touch $out + ''; + }); + }; +} diff --git a/module.nix b/module.nix new file mode 100644 index 0000000..1f53d21 --- /dev/null +++ b/module.nix @@ -0,0 +1,51 @@ +self: { config, lib, pkgs, ... }: +let + cfg = config.services.stardew-tracker; +in +{ + options.services.stardew-tracker = { + enable = lib.mkEnableOption "the shared Stardew Valley two-year tracker"; + package = lib.mkOption { + type = lib.types.package; + default = self.packages.${pkgs.stdenv.hostPlatform.system}.default; + defaultText = lib.literalExpression "stardew-tracker.packages.${pkgs.stdenv.hostPlatform.system}.default"; + description = "The stardew-tracker package to run."; + }; + port = lib.mkOption { + type = lib.types.port; + default = 8089; + description = "Loopback TCP port used by the tracker."; + }; + }; + + config = lib.mkIf cfg.enable { + users.users.stardew-tracker = { + isSystemUser = true; + group = "stardew-tracker"; + description = "Stardew tracker service user"; + }; + users.groups.stardew-tracker = { }; + + systemd.services.stardew-tracker = { + description = "Shared Stardew Valley tracker"; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ]; + serviceConfig = { + ExecStart = "${cfg.package}/bin/stardew-tracker --host 127.0.0.1 --port ${toString cfg.port} --database /var/lib/stardew-tracker/progress.sqlite3"; + User = "stardew-tracker"; + Group = "stardew-tracker"; + StateDirectory = "stardew-tracker"; + Restart = "on-failure"; + RestartSec = 3; + NoNewPrivileges = true; + PrivateTmp = true; + ProtectSystem = "strict"; + ProtectHome = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectControlGroups = true; + RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ]; + }; + }; + }; +} diff --git a/package.nix b/package.nix new file mode 100644 index 0000000..9f9fa44 --- /dev/null +++ b/package.nix @@ -0,0 +1,26 @@ +{ lib, stdenvNoCC, makeWrapper, python3 }: + +stdenvNoCC.mkDerivation { + pname = "stardew-tracker"; + version = "1.0.0"; + src = ./.; + + nativeBuildInputs = [ makeWrapper ]; + + installPhase = '' + runHook preInstall + mkdir -p $out/share/stardew-tracker $out/bin + cp app.py $out/share/stardew-tracker/app.py + cp -r static $out/share/stardew-tracker/static + makeWrapper ${python3}/bin/python3 $out/bin/stardew-tracker \ + --add-flags "$out/share/stardew-tracker/app.py" + runHook postInstall + ''; + + meta = { + description = "Shared two-year Stardew Valley Community Center planner"; + license = lib.licenses.mit; + platforms = lib.platforms.linux; + mainProgram = "stardew-tracker"; + }; +} diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..7c64b42 --- /dev/null +++ b/static/app.js @@ -0,0 +1,292 @@ +(() => { + const data = window.VALLEY_DATA; + const weekdays = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]; + const seasonNames = { spring: "Spring", summer: "Summer", fall: "Fall", winter: "Winter" }; + const state = { + year: Number(localStorage.getItem("valley-year")) || 1, + season: localStorage.getItem("valley-season") || "spring", + day: Number(localStorage.getItem("valley-day")) || 1, + filter: "all", + checks: {}, + revision: -1, + saving: new Set() + }; + + const $ = (selector, root = document) => root.querySelector(selector); + const $$ = (selector, root = document) => [...root.querySelectorAll(selector)]; + const escapeHtml = (value) => String(value).replace(/[&<>'"]/g, char => ({ "&": "&", "<": "<", ">": ">", "'": "'", '"': """ }[char])); + const itemKey = crop => crop.sharedId || crop.id; + + function saveDate() { + localStorage.setItem("valley-year", state.year); + localStorage.setItem("valley-season", state.season); + localStorage.setItem("valley-day", state.day); + } + + function weekday(day = state.day) { return weekdays[(day - 1) % 7]; } + + function setDate({ year = state.year, season = state.season, day = state.day }) { + state.year = Math.max(1, Math.min(2, year)); + state.season = data.seasons.includes(season) ? season : "spring"; + state.day = Math.max(1, Math.min(28, day)); + saveDate(); + renderDate(); + renderToday(); + renderCrops(); + if (state.filter === "season") renderBundles(); + } + + function moveDay(delta) { + let index = data.seasons.indexOf(state.season); + let day = state.day + delta; + let year = state.year; + if (day > 28) { day = 1; index += 1; } + if (day < 1) { day = 28; index -= 1; } + if (index > 3) { index = 0; year += 1; } + if (index < 0) { index = 3; year -= 1; } + if (year < 1 || year > 2) return; + setDate({ year, season: data.seasons[index], day }); + } + + function renderDate() { + document.body.dataset.season = state.season; + $("#season-title").textContent = seasonNames[state.season]; + $("#day-title").textContent = state.day; + $("#weekday-title").textContent = `${weekday()} · Year ${state.year}`; + $$("[data-year]").forEach(button => button.setAttribute("aria-pressed", String(Number(button.dataset.year) === state.year))); + $$("[data-season]").forEach(button => button.setAttribute("aria-selected", String(button.dataset.season === state.season))); + $("#day-grid").innerHTML = Array.from({ length: 28 }, (_, index) => { + const day = index + 1; + return ``; + }).join(""); + } + + function currentBirthday() { + const entry = data.birthdays[state.season][state.day]; + if (!entry) return null; + if (entry[1] && entry[1] !== state.year) return null; + return entry[0]; + } + + function currentEvent() { + const entry = data.events[state.season][state.day]; + if (!entry) return null; + if (entry[2] && entry[2] !== state.year) return null; + return entry; + } + + function reminders() { + const name = weekday(); + const result = []; + if (name === "Friday" || name === "Sunday") result.push(["Traveling Cart", "Cindersap Forest, 6am–8pm. Check for off-season bundle items.", "cart"]); + if (name === "Sunday") result.push(["The Queen of Sauce", "Watch TV for a new recipe during Years 1–2.", "tv"]); + if (name === "Wednesday") result.push(["Recipe rerun", "The Queen of Sauce repeats a recipe you may have missed.", "tv"]); + if (name === "Monday" && (state.year > 1 || state.season !== "spring" || state.day >= 2)) result.push(["New week", "Special Order boards refresh on Monday once unlocked.", "board"]); + return result; + } + + function cropDeadlineCards() { + return data.crops.filter(crop => crop.season === state.season && crop.last === state.day && (!crop.year || crop.year === state.year)); + } + + function card(kind, label, title, copy) { + return `
${escapeHtml(label)}

${escapeHtml(title)}

${escapeHtml(copy)}

`; + } + + function renderToday() { + const cards = []; + const birthday = currentBirthday(); + const event = currentEvent(); + if (birthday) cards.push(card("birthday", "Birthday", birthday, "A birthday gift gives 8× the usual friendship effect.")); + if (event) cards.push(card("event", "Event", event[0], event[1])); + reminders().forEach(([title, copy]) => cards.push(card("reminder", "Reminder", title, copy))); + cropDeadlineCards().forEach(crop => cards.push(card("deadline", "Plant by today", crop.name, crop.purpose))); + if (!cards.length) cards.push(card("quiet", "Open day", "Make your own luck", "No fixed birthdays, festivals, or last planting dates today.")); + $("#today-cards").innerHTML = cards.join(""); + const noun = cards.length === 1 ? "thing" : "things"; + $("#today-summary").textContent = `${cards.length} ${noun} on the calendar`; + } + + function renderCrops() { + const crops = data.crops.filter(crop => crop.season === state.season && (!crop.year || crop.year === state.year)); + const rows = crops.map(crop => { + const key = itemKey(crop); + const checked = Boolean(state.checks[key]); + const overdue = state.day > crop.last && !checked; + const today = state.day === crop.last && !checked; + return ``; + }); + $("#crop-list").innerHTML = rows.length ? rows.join("") : `
❄

No mandatory field crops

Use Winter for the foraging bundle, mines, fishing, and any unfinished animal or artisan goods.

`; + } + + function groupProgress(group) { + const done = group.items.filter(entry => state.checks[entry.id]).length; + return { done, required: group.required, complete: done >= group.required }; + } + + function bundleTotals() { + return data.bundles.reduce((totals, room) => room.groups.reduce((inner, group) => { + const { done, required } = groupProgress(group); + inner.done += Math.min(done, required); + inner.required += required; + return inner; + }, totals), { done: 0, required: 0 }); + } + + function renderBundles() { + const html = data.bundles.map(room => { + const groups = room.groups.map(group => { + const progress = groupProgress(group); + let items = group.items; + if (state.filter === "season") items = items.filter(entry => !entry.seasons.length || entry.seasons.includes(state.season)); + if (state.filter === "open") items = items.filter(entry => !state.checks[entry.id]); + if (!items.length) return ""; + return `
+

${escapeHtml(group.name)}

${group.choose ? `Choose ${group.required}` : ""}
${Math.min(progress.done, progress.required)}/${progress.required}
+
${items.map(entry => { + const checked = Boolean(state.checks[entry.id]); + return ``; + }).join("")}
+
`; + }).filter(Boolean).join(""); + if (!groups) return ""; + const roomGroups = room.groups.map(groupProgress); + const roomDone = roomGroups.filter(progress => progress.complete).length; + return `
${room.icon}

${escapeHtml(room.room)}

${roomDone} of ${room.groups.length} bundles ready

${groups}
`; + }).filter(Boolean).join(""); + $("#bundle-list").innerHTML = html || `
✓

Everything in this view is done

Switch filters to review the whole Community Center.

`; + const totals = bundleTotals(); + const percent = totals.required ? Math.round(totals.done / totals.required * 100) : 0; + $("#progress-label").textContent = `${totals.done} of ${totals.required} essentials`; + $("#progress-bar").style.width = `${percent}%`; + } + + function setSyncStatus(mode, text) { + const node = $("#sync-status"); + node.dataset.mode = mode; + node.lastElementChild.textContent = text; + } + + function toast(message) { + const node = $("#toast"); + node.textContent = message; + node.classList.add("show"); + clearTimeout(toast.timer); + toast.timer = setTimeout(() => node.classList.remove("show"), 2600); + } + + async function loadState({ quiet = false } = {}) { + if (!quiet) setSyncStatus("loading", "Connecting…"); + try { + const response = await fetch("/api/state", { cache: "no-store" }); + if (!response.ok) throw new Error(`State request failed (${response.status})`); + const snapshot = await response.json(); + if (snapshot.revision !== state.revision) { + state.checks = snapshot.checks || {}; + state.revision = snapshot.revision; + renderCrops(); + renderBundles(); + } + setSyncStatus("ready", "Shared progress synced"); + } catch (error) { + console.error(error); + setSyncStatus("error", "Sync unavailable"); + } + } + + async function updateCheck(itemId, checked) { + const previous = Boolean(state.checks[itemId]); + state.checks[itemId] = checked; + state.saving.add(itemId); + renderCrops(); + renderBundles(); + setSyncStatus("loading", "Saving…"); + try { + const response = await fetch("/api/checks", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ itemId, checked }) + }); + if (!response.ok) throw new Error(`Save failed (${response.status})`); + const result = await response.json(); + state.revision = Math.max(state.revision, result.revision); + setSyncStatus("ready", "Shared progress synced"); + } catch (error) { + console.error(error); + state.checks[itemId] = previous; + setSyncStatus("error", "Couldn’t save"); + toast("That change wasn’t saved. Your previous shared state is restored."); + } finally { + state.saving.delete(itemId); + renderCrops(); + renderBundles(); + } + } + + function installWebMcp() { + const context = document.modelContext; + if (!context?.registerTool) return; + const knownIds = new Set(data.bundles.flatMap(room => room.groups.flatMap(group => group.items.map(entry => entry.id)))); + void Promise.resolve(context.registerTool({ + name: "get_valley_progress", + title: "Get Valley progress", + description: "Read the currently selected game date and shared Community Center checklist progress.", + inputSchema: { type: "object", properties: {}, additionalProperties: false }, + annotations: { readOnlyHint: true, untrustedContentHint: false }, + execute: () => ({ year: state.year, season: state.season, day: state.day, progress: bundleTotals(), completedItemIds: Object.keys(state.checks).filter(id => state.checks[id]) }) + })).catch(console.error); + void Promise.resolve(context.registerTool({ + name: "set_valley_checklist_item", + title: "Update Valley checklist", + description: "Check or uncheck one known Community Center item in the shared household plan.", + inputSchema: { type: "object", properties: { itemId: { type: "string" }, checked: { type: "boolean" } }, required: ["itemId", "checked"], additionalProperties: false }, + annotations: { readOnlyHint: false, untrustedContentHint: false }, + async execute(input) { + if (!input || !knownIds.has(input.itemId) || typeof input.checked !== "boolean") throw new Error("Unknown checklist item or invalid checked value"); + await updateCheck(input.itemId, input.checked); + return { itemId: input.itemId, checked: Boolean(state.checks[input.itemId]), revision: state.revision }; + } + })).catch(console.error); + } + + document.addEventListener("click", event => { + const yearButton = event.target.closest("[data-year]"); + if (yearButton) setDate({ year: Number(yearButton.dataset.year) }); + const seasonButton = event.target.closest("[data-season]"); + if (seasonButton) setDate({ season: seasonButton.dataset.season }); + const dayButton = event.target.closest("[data-day]"); + if (dayButton) setDate({ day: Number(dayButton.dataset.day) }); + const filterButton = event.target.closest("[data-filter]"); + if (filterButton) { + state.filter = filterButton.dataset.filter; + $$("[data-filter]").forEach(button => button.setAttribute("aria-pressed", String(button === filterButton))); + renderBundles(); + } + const navLink = event.target.closest(".section-nav a"); + if (navLink) { + $$(".section-nav a").forEach(link => link.classList.toggle("active", link === navLink)); + } + }); + + document.addEventListener("change", event => { + const input = event.target.closest("[data-check-id]"); + if (input) updateCheck(input.dataset.checkId, input.checked); + }); + + $("#previous-day").addEventListener("click", () => moveDay(-1)); + $("#next-day").addEventListener("click", () => moveDay(1)); + + renderDate(); + renderToday(); + renderCrops(); + renderBundles(); + loadState(); + setInterval(() => loadState({ quiet: true }), 4000); + installWebMcp(); +})(); diff --git a/static/data.js b/static/data.js new file mode 100644 index 0000000..968f64f --- /dev/null +++ b/static/data.js @@ -0,0 +1,116 @@ +window.VALLEY_DATA = (() => { + const birthdays = { + spring: { 4: ["Kent (Year 2+)", 2], 7: ["Lewis"], 10: ["Vincent"], 14: ["Haley"], 18: ["Pam"], 20: ["Shane"], 26: ["Pierre"], 27: ["Emily"] }, + summer: { 4: ["Jas"], 8: ["Gus"], 10: ["Maru"], 13: ["Alex"], 17: ["Sam"], 19: ["Demetrius"], 22: ["Dwarf"], 24: ["Willy"], 26: ["Leo"] }, + fall: { 2: ["Penny"], 5: ["Elliott"], 11: ["Jodi"], 13: ["Abigail"], 15: ["Sandy"], 18: ["Marnie"], 21: ["Robin"], 24: ["George"] }, + winter: { 1: ["Krobus"], 3: ["Linus"], 7: ["Caroline"], 10: ["Sebastian"], 14: ["Harvey"], 17: ["Wizard"], 20: ["Evelyn"], 23: ["Leah"], 26: ["Clint"] } + }; + + const events = { + spring: { + 13: ["Egg Festival", "Pelican Town, 9am–2pm. Strawberry Seeds are the prize purchase."], + 15: ["Desert Festival · day 1", "10am–2am; the bus must be repaired."], + 16: ["Desert Festival · day 2", "10am–2am; the bus must be repaired."], + 17: ["Desert Festival · day 3", "10am–2am. A pot of gold also appears in Cindersap Forest."], + 24: ["Flower Dance", "Cindersap Forest, 9am–2pm."] + }, + summer: { + 3: ["Railroad opens", "In Year 1, the overnight earthquake opens the railroad and spa.", 1], + 11: ["Luau", "The Beach, 9am–2pm. Bring a good ingredient for the soup."], + 12: ["Beach forage", "Extra forageables appear at the beach through Summer 14."], + 13: ["Beach forage", "Extra forageables appear at the beach through Summer 14."], + 14: ["Beach forage", "Last day of the extra beach forage window."], + 20: ["Trout Derby · day 1", "Cindersap Forest, 6:10am–2am."], + 21: ["Trout Derby · day 2", "Cindersap Forest, 6:10am–2am."], + 28: ["Dance of the Moonlight Jellies", "The Beach, 10pm–12am."] + }, + fall: { + 8: ["Blackberry season", "Shake berry bushes through Fall 11."], + 9: ["Blackberry season", "Shake berry bushes through Fall 11."], + 10: ["Blackberry season", "Shake berry bushes through Fall 11."], + 11: ["Blackberry season", "Last day to shake blackberry bushes."], + 16: ["Stardew Valley Fair", "Pelican Town, 9am–3pm. Bring nine strong grange-display items."], + 27: ["Spirit’s Eve", "Pelican Town, 10pm–11:50pm."] + }, + winter: { + 8: ["Festival of Ice", "Cindersap Forest, 9am–2pm."], + 12: ["SquidFest · day 1", "The Beach, 6:10am–2am."], + 13: ["SquidFest · day 2", "The Beach, 6:10am–2am."], + 15: ["Night Market · night 1", "The Beach, 5pm–2am. Check the traveling merchant and submarine."], + 16: ["Night Market · night 2", "The Beach, 5pm–2am."], + 17: ["Night Market · night 3", "The Beach, 5pm–2am."], + 25: ["Feast of the Winter Star", "Pelican Town, 9am–2pm. Bring your secret friend’s gift."], + 28: ["Year-end check", "Final day before the next spring. Winter 28, Year 2 leads into Grandpa’s evaluation.", 2] + } + }; + + const crops = [ + { id: "crop-parsnip", name: "Parsnip", season: "spring", grow: 4, last: 24, purpose: "Spring Crops · also grow 5 gold-quality for Quality Crops" }, + { id: "crop-green-bean", name: "Green Bean", season: "spring", grow: 10, last: 18, purpose: "Spring Crops · trellis" }, + { id: "crop-potato", name: "Potato", season: "spring", grow: 6, last: 22, purpose: "Spring Crops" }, + { id: "crop-cauliflower", name: "Cauliflower", season: "spring", grow: 12, last: 16, purpose: "Spring Crops" }, + { id: "crop-blueberry", name: "Blueberry", season: "summer", grow: 13, last: 15, purpose: "Summer Crops" }, + { id: "crop-melon", name: "Melon", season: "summer", grow: 12, last: 16, purpose: "Summer Crops · also grow 5 gold-quality for Quality Crops" }, + { id: "crop-hot-pepper", name: "Hot Pepper", season: "summer", grow: 5, last: 23, purpose: "Summer Crops" }, + { id: "crop-tomato", name: "Tomato", season: "summer", grow: 11, last: 17, purpose: "Summer Crops" }, + { id: "crop-red-cabbage", name: "Red Cabbage", season: "summer", grow: 9, last: 19, year: 2, purpose: "Dye Bundle · Year 2 seed, or Traveling Cart in Year 1" }, + { id: "crop-corn", name: "Corn", season: "summer", grow: 14, last: 14, purpose: "Fall Crops · grows through Fall; 5 gold-quality can count for Quality Crops" }, + { id: "crop-poppy", name: "Poppy", season: "summer", grow: 7, last: 21, purpose: "Chef’s Bundle" }, + { id: "crop-sunflower", name: "Sunflower", season: "summer", grow: 8, last: 20, purpose: "Dye Bundle · also grows in Fall" }, + { id: "crop-wheat-10", name: "Wheat ×10", season: "summer", grow: 4, last: 24, purpose: "Fodder Bundle · also grows in Fall" }, + { id: "tree-apple", name: "Apple Tree", season: "summer", grow: 28, last: 25, purpose: "Plant by Summer 25 to collect 3 apples by Fall 28" }, + { id: "tree-pomegranate", name: "Pomegranate Tree", season: "summer", grow: 28, last: 27, purpose: "Plant by late Summer for an Enchanter’s Bundle fruit in Fall" }, + { id: "crop-eggplant", name: "Eggplant", season: "fall", grow: 5, last: 23, purpose: "Fall Crops" }, + { id: "crop-pumpkin", name: "Pumpkin", season: "fall", grow: 13, last: 15, purpose: "Fall Crops · also grow 5 gold-quality for Quality Crops" }, + { id: "crop-yam", name: "Yam", season: "fall", grow: 10, last: 18, purpose: "Fall Crops" }, + { id: "crop-corn-fall", sharedId: "crop-corn", name: "Corn", season: "fall", grow: 14, last: 14, purpose: "Fall Crops · skip if your Summer corn is still growing" }, + { id: "crop-sunflower-fall", sharedId: "crop-sunflower", name: "Sunflower", season: "fall", grow: 8, last: 20, purpose: "Dye Bundle · skip if completed in Summer" }, + { id: "crop-wheat-fall", sharedId: "crop-wheat-10", name: "Wheat ×10", season: "fall", grow: 4, last: 24, purpose: "Fodder Bundle · skip if completed in Summer" }, + { id: "crop-winter-seeds", name: "Winter forage set", season: "winter", grow: 7, last: 21, purpose: "Optional: craft Winter Seeds after gathering the four Winter Foraging items" } + ]; + + const item = (id, label, meta, seasons = []) => ({ id, label, meta, seasons }); + const bundles = [ + { room: "Crafts Room", icon: "⌂", groups: [ + { name: "Spring Foraging", required: 4, items: [item("forage-wild-horseradish", "Wild Horseradish", "Spring forage", ["spring"]), item("forage-daffodil", "Daffodil", "Spring forage", ["spring"]), item("forage-leek", "Leek", "Spring forage", ["spring"]), item("forage-dandelion", "Dandelion", "Spring forage", ["spring"])] }, + { name: "Summer Foraging", required: 3, items: [item("forage-grape", "Grape", "Summer forage", ["summer"]), item("forage-spice-berry", "Spice Berry", "Summer forage", ["summer"]), item("forage-sweet-pea", "Sweet Pea", "Summer forage", ["summer"])] }, + { name: "Fall Foraging", required: 4, items: [item("forage-common-mushroom", "Common Mushroom", "Fall forage", ["fall"]), item("forage-wild-plum", "Wild Plum", "Fall forage", ["fall"]), item("forage-hazelnut", "Hazelnut", "Fall forage", ["fall"]), item("forage-blackberry", "Blackberry", "Fall forage; bushes Fall 8–11", ["fall"])] }, + { name: "Winter Foraging", required: 4, items: [item("forage-winter-root", "Winter Root", "Dig soil/artifact spots", ["winter"]), item("forage-crystal-fruit", "Crystal Fruit", "Winter forage", ["winter"]), item("forage-snow-yam", "Snow Yam", "Dig soil/artifact spots", ["winter"]), item("forage-crocus", "Crocus", "Winter forage", ["winter"])] }, + { name: "Construction", required: 3, items: [item("construction-wood-a", "Wood ×99", "Chop trees"), item("construction-wood-b", "Wood ×99", "A second stack"), item("construction-hardwood", "Hardwood ×10", "Large stumps / Secret Woods")] }, + { name: "Exotic Foraging", required: 5, choose: true, items: [item("exotic-coconut", "Coconut", "Desert forage"), item("exotic-cactus-fruit", "Cactus Fruit", "Desert forage"), item("exotic-cave-carrot", "Cave Carrot", "Mine crates / soil"), item("exotic-red-mushroom", "Red Mushroom", "Mines / cave"), item("exotic-purple-mushroom", "Purple Mushroom", "Mines / cave"), item("exotic-maple-syrup", "Maple Syrup", "Tap maple tree"), item("exotic-oak-resin", "Oak Resin", "Tap oak tree"), item("exotic-pine-tar", "Pine Tar", "Tap pine tree"), item("exotic-morel", "Morel", "Secret Woods in Spring", ["spring"])] } + ]}, + { room: "Pantry", icon: "◒", groups: [ + { name: "Spring Crops", required: 4, items: [item("crop-parsnip", "Parsnip", "4 days · plant by Spring 24", ["spring"]), item("crop-green-bean", "Green Bean", "10 days · plant by Spring 18", ["spring"]), item("crop-cauliflower", "Cauliflower", "12 days · plant by Spring 16", ["spring"]), item("crop-potato", "Potato", "6 days · plant by Spring 22", ["spring"])] }, + { name: "Summer Crops", required: 4, items: [item("crop-tomato", "Tomato", "11 days · plant by Summer 17", ["summer"]), item("crop-hot-pepper", "Hot Pepper", "5 days · plant by Summer 23", ["summer"]), item("crop-blueberry", "Blueberry", "13 days · plant by Summer 15", ["summer"]), item("crop-melon", "Melon", "12 days · plant by Summer 16", ["summer"])] }, + { name: "Fall Crops", required: 4, items: [item("crop-corn", "Corn", "14 days · Summer/Fall", ["summer", "fall"]), item("crop-eggplant", "Eggplant", "5 days · plant by Fall 23", ["fall"]), item("crop-pumpkin", "Pumpkin", "13 days · plant by Fall 15", ["fall"]), item("crop-yam", "Yam", "10 days · plant by Fall 18", ["fall"])] }, + { name: "Quality Crops", required: 3, choose: true, items: [item("quality-parsnip", "Gold Parsnip ×5", "Basic Fertilizer helps", ["spring"]), item("quality-melon", "Gold Melon ×5", "Basic Fertilizer helps", ["summer"]), item("quality-pumpkin", "Gold Pumpkin ×5", "Basic Fertilizer helps", ["fall"]), item("quality-corn", "Gold Corn ×5", "Summer/Fall", ["summer", "fall"])] }, + { name: "Animal", required: 5, choose: true, items: [item("animal-large-milk", "Large Milk", "Happy cow"), item("animal-large-egg-brown", "Large Brown Egg", "Happy brown chicken"), item("animal-large-egg-white", "Large White Egg", "Happy white chicken"), item("animal-large-goat-milk", "Large Goat Milk", "Happy goat"), item("animal-wool", "Wool", "Sheep or rabbit"), item("animal-duck-egg", "Duck Egg", "Duck")] }, + { name: "Artisan", required: 6, choose: true, items: [item("artisan-truffle-oil", "Truffle Oil", "Oil Maker"), item("artisan-cloth", "Cloth", "Loom / recycling"), item("artisan-goat-cheese", "Goat Cheese", "Cheese Press"), item("artisan-cheese", "Cheese", "Cheese Press"), item("artisan-honey", "Honey", "Bee House"), item("artisan-jelly", "Jelly", "Preserves Jar"), item("artisan-apple", "Apple", "Fall fruit tree", ["fall"]), item("artisan-apricot", "Apricot", "Spring fruit tree", ["spring"]), item("artisan-orange", "Orange", "Summer fruit tree", ["summer"]), item("artisan-peach", "Peach", "Summer fruit tree", ["summer"]), item("artisan-pomegranate", "Pomegranate", "Fall fruit tree", ["fall"]), item("artisan-cherry", "Cherry", "Spring fruit tree", ["spring"])] } + ]}, + { room: "Fish Tank", icon: "≈", groups: [ + { name: "River Fish", required: 4, items: [item("fish-sunfish", "Sunfish", "River · 6am–7pm · sunny", ["spring", "summer"]), item("fish-catfish", "Catfish", "River · 6am–12am · rain", ["spring", "fall"]), item("fish-shad", "Shad", "River · 9am–2am · rain", ["spring", "summer", "fall"]), item("fish-tiger-trout", "Tiger Trout", "River · 6am–7pm", ["fall", "winter"])] }, + { name: "Lake Fish", required: 4, items: [item("fish-largemouth-bass", "Largemouth Bass", "Mountain Lake · 6am–7pm", ["spring", "summer", "fall", "winter"]), item("fish-carp", "Carp", "Mountain Lake · any time", ["spring", "summer", "fall", "winter"]), item("fish-bullhead", "Bullhead", "Mountain Lake · any time", ["spring", "summer", "fall", "winter"]), item("fish-sturgeon", "Sturgeon", "Mountain Lake · 6am–7pm", ["summer", "winter"])] }, + { name: "Ocean Fish", required: 4, items: [item("fish-sardine", "Sardine", "Ocean · 6am–7pm", ["spring", "fall", "winter"]), item("fish-tuna", "Tuna", "Ocean · 6am–7pm", ["summer", "winter"]), item("fish-red-snapper", "Red Snapper", "Ocean · 6am–7pm · rain", ["summer", "fall"]), item("fish-tilapia", "Tilapia", "Ocean · 6am–2pm", ["summer", "fall"])] }, + { name: "Night Fishing", required: 3, items: [item("fish-walleye", "Walleye", "River/Lake · 12pm–2am · rain", ["fall"]), item("fish-bream", "Bream", "River · 6pm–2am", ["spring", "summer", "fall", "winter"]), item("fish-eel", "Eel", "Ocean · 4pm–2am · rain", ["spring", "fall"])] }, + { name: "Specialty Fish", required: 4, items: [item("fish-pufferfish", "Pufferfish", "Ocean · 12pm–4pm · sunny", ["summer"]), item("fish-ghostfish", "Ghostfish", "Mines floors 20/60"), item("fish-sandfish", "Sandfish", "Desert pond · 6am–8pm"), item("fish-woodskip", "Woodskip", "Secret Woods · any time")] }, + { name: "Crab Pot", required: 5, choose: true, items: [item("crab-lobster", "Lobster", "Ocean crab pot"), item("crab-crayfish", "Crayfish", "Freshwater crab pot"), item("crab-crab", "Crab", "Ocean crab pot / rock crab"), item("crab-cockle", "Cockle", "Ocean crab pot / beach"), item("crab-mussel", "Mussel", "Ocean crab pot / beach"), item("crab-shrimp", "Shrimp", "Ocean crab pot"), item("crab-snail", "Snail", "Freshwater crab pot"), item("crab-periwinkle", "Periwinkle", "Freshwater crab pot"), item("crab-oyster", "Oyster", "Ocean crab pot / beach"), item("crab-clam", "Clam", "Ocean crab pot / beach")] } + ]}, + { room: "Boiler Room", icon: "♨", groups: [ + { name: "Blacksmith’s", required: 3, items: [item("bar-copper", "Copper Bar", "Smelt 5 ore"), item("bar-iron", "Iron Bar", "Smelt 5 ore"), item("bar-gold", "Gold Bar", "Smelt 5 ore")] }, + { name: "Geologist’s", required: 4, items: [item("mineral-quartz", "Quartz", "Mines"), item("mineral-earth-crystal", "Earth Crystal", "Mines floors 1–39"), item("mineral-frozen-tear", "Frozen Tear", "Mines floors 40–79"), item("mineral-fire-quartz", "Fire Quartz", "Mines floors 80+")] }, + { name: "Adventurer’s", required: 2, choose: true, items: [item("monster-slime", "Slime ×99", "Slimes"), item("monster-bat-wing", "Bat Wing ×10", "Bats"), item("monster-solar-essence", "Solar Essence", "Ghost / Squid Kid"), item("monster-void-essence", "Void Essence", "Shadow Brute / Shaman")] } + ]}, + { room: "Bulletin Board", icon: "✦", groups: [ + { name: "Chef’s", required: 6, items: [item("chef-maple-syrup", "Maple Syrup", "Tap maple tree"), item("chef-fiddlehead", "Fiddlehead Fern", "Secret Woods", ["summer"]), item("chef-truffle", "Truffle", "Adult pig outdoors"), item("crop-poppy", "Poppy", "7 days · plant by Summer 21", ["summer"]), item("chef-maki-roll", "Maki Roll", "Cook / Saloon / Krobus"), item("chef-fried-egg", "Fried Egg", "Cook one egg")] }, + { name: "Dye", required: 6, items: [item("dye-red-mushroom", "Red Mushroom", "Mines / cave"), item("dye-sea-urchin", "Sea Urchin", "Beach tide pools"), item("crop-sunflower", "Sunflower", "8 days · Summer/Fall", ["summer", "fall"]), item("dye-duck-feather", "Duck Feather", "Happy duck"), item("dye-aquamarine", "Aquamarine", "Mines floors 40+"), item("crop-red-cabbage", "Red Cabbage", "Year 2 or Year 1 Traveling Cart", ["summer"])] }, + { name: "Field Research", required: 4, items: [item("field-purple-mushroom", "Purple Mushroom", "Mines / cave"), item("field-nautilus-shell", "Nautilus Shell", "Beach forage", ["winter"]), item("field-chub", "Chub", "Mountain Lake / forest river", ["spring", "summer", "fall", "winter"]), item("field-frozen-geode", "Frozen Geode", "Mines floors 40–79")] }, + { name: "Fodder", required: 3, items: [item("crop-wheat-10", "Wheat ×10", "4 days · Summer/Fall", ["summer", "fall"]), item("fodder-hay", "Hay ×10", "Silo / Marnie"), item("fodder-apple", "Apple ×3", "Fall fruit tree", ["fall"])] }, + { name: "Enchanter’s", required: 4, items: [item("enchanter-oak-resin", "Oak Resin", "Tap oak tree"), item("enchanter-wine", "Wine", "Keg"), item("enchanter-rabbits-foot", "Rabbit’s Foot", "Happy rabbit / Serpent"), item("enchanter-pomegranate", "Pomegranate", "Fall fruit tree", ["fall"])] } + ]}, + { room: "Vault", icon: "◇", groups: [ + { name: "Cash Bundles", required: 4, items: [item("vault-2500", "2,500g", "Purchase at the Vault"), item("vault-5000", "5,000g", "Purchase at the Vault"), item("vault-10000", "10,000g", "Purchase at the Vault"), item("vault-25000", "25,000g", "Purchase at the Vault · total 42,500g")] } + ]} + ]; + + return { birthdays, events, crops, bundles, seasons: ["spring", "summer", "fall", "winter"] }; +})(); diff --git a/static/favicon.svg b/static/favicon.svg new file mode 100644 index 0000000..24cdb3c --- /dev/null +++ b/static/favicon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..99e6e44 --- /dev/null +++ b/static/index.html @@ -0,0 +1,100 @@ + + + + + + + + + + Valley Yearbook + + +
+
+ + + Valley YearbookTwo years, one shared plan + +
+ Connecting… +
+
+ +
+
+
+
+

Farm calendar

+

Spring 1

+

Monday · Year 1

+
+
+ + +
+
+ +
+ + +
+ +
+ + + + +
+ +
+
+ + + +
+
+

At a glance

Today in the valley

+

+
+
+
+ +
+
+

No Speed-Gro assumed

Planting plan

+

Last safe dates assume the crop is watered on planting day.

+
+
+
+ +
+
+

Standard bundles

Community Center

+
+ 0 of 0 essentials + +
+
+
+ + + +
+
+
+
+ +
+

Unofficial fan-made planner for Stardew Valley 1.6. Calendar and standard-bundle details are based on the Stardew Valley Wiki.

+
+
+ + + + + diff --git a/static/styles.css b/static/styles.css new file mode 100644 index 0000000..38d19fa --- /dev/null +++ b/static/styles.css @@ -0,0 +1,188 @@ +:root { + color-scheme: dark; + --ink: #f8f1dc; + --muted: #aebbb2; + --deep: #0c1814; + --panel: #132720; + --panel-2: #193229; + --line: rgba(242, 232, 200, .13); + --accent: #f1c96d; + --season: #79b56b; + --season-soft: rgba(121, 181, 107, .16); + --danger: #f08b5d; + --radius: 18px; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-size: 16px; +} + +body[data-season="summer"] { --season: #f0b84f; --season-soft: rgba(240, 184, 79, .16); } +body[data-season="fall"] { --season: #d27849; --season-soft: rgba(210, 120, 73, .17); } +body[data-season="winter"] { --season: #86bcd8; --season-soft: rgba(134, 188, 216, .16); } + +* { box-sizing: border-box; } +html { scroll-behavior: smooth; } +body { + margin: 0; + min-width: 320px; + background: + radial-gradient(circle at 50% -10%, rgba(55, 107, 85, .23), transparent 40rem), + var(--deep); + color: var(--ink); + min-height: 100vh; + line-height: 1.5; +} +button, input { font: inherit; } +button, a { -webkit-tap-highlight-color: transparent; } +button { color: inherit; } +a { color: inherit; } +.app-shell { width: min(1180px, calc(100% - 32px)); margin: 0 auto; } + +.topbar { + min-height: 84px; + display: flex; + justify-content: space-between; + align-items: center; + gap: 20px; + border-bottom: 1px solid var(--line); +} +.brand { display: flex; align-items: center; gap: 12px; text-decoration: none; } +.brand strong { display: block; font-family: Georgia, serif; font-size: 1.18rem; letter-spacing: .01em; } +.brand small { display: block; color: var(--muted); font-size: .78rem; margin-top: 1px; } +.brand-mark { width: 34px; height: 34px; display: grid; grid-template-columns: 1fr 1fr; gap: 3px; padding: 5px; background: var(--ink); border-radius: 10px; transform: rotate(-2deg); } +.brand-mark i { display: block; border-radius: 2px; background: #79b56b; } +.brand-mark i:nth-child(2) { background: #f0b84f; } +.brand-mark i:nth-child(3) { background: #d27849; } +.brand-mark i:nth-child(4) { background: #86bcd8; } +.sync-status { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: .84rem; white-space: nowrap; } +.sync-dot { width: 8px; height: 8px; border-radius: 50%; background: #87958c; box-shadow: 0 0 0 4px rgba(135,149,140,.1); } +.sync-status[data-mode="ready"] .sync-dot { background: #7bc47f; box-shadow: 0 0 0 4px rgba(123,196,127,.12); } +.sync-status[data-mode="error"] .sync-dot { background: var(--danger); box-shadow: 0 0 0 4px rgba(240,139,93,.12); } + +.date-deck { padding: 44px 0 30px; } +.date-heading { display: flex; justify-content: space-between; align-items: end; gap: 20px; } +.eyebrow { margin: 0 0 7px; text-transform: uppercase; letter-spacing: .16em; color: var(--season); font-weight: 800; font-size: .74rem; } +h1, h2, h3, h4, p { margin-top: 0; } +h1 { margin-bottom: 3px; font: 700 clamp(2.8rem, 8vw, 5.8rem)/.92 Georgia, serif; letter-spacing: -.045em; } +.date-heading > div > p:last-child { margin: 13px 0 0; color: var(--muted); font-size: 1rem; } +.date-arrows { display: flex; gap: 8px; } +.date-arrows button { width: 48px; height: 48px; border: 1px solid var(--line); border-radius: 50%; background: var(--panel); cursor: pointer; font-size: 1.4rem; transition: .18s ease; } +.date-arrows button:hover { border-color: var(--season); background: var(--season-soft); transform: translateY(-2px); } + +.year-switch { display: inline-flex; margin: 30px 0 18px; padding: 4px; background: var(--panel); border: 1px solid var(--line); border-radius: 999px; } +.year-switch button, .bundle-toolbar button { border: 0; background: transparent; padding: 9px 16px; border-radius: 999px; color: var(--muted); cursor: pointer; font-weight: 700; font-size: .88rem; } +.year-switch button[aria-pressed="true"], .bundle-toolbar button[aria-pressed="true"] { background: var(--ink); color: var(--deep); } +.season-tabs { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; } +.season-tabs button { border: 1px solid var(--line); background: var(--panel); padding: 14px 12px; border-radius: 13px; cursor: pointer; color: var(--muted); font-weight: 750; transition: .18s ease; } +.season-tabs button span { margin-right: 7px; } +.season-tabs button[aria-selected="true"] { color: var(--ink); border-color: var(--season); background: var(--season-soft); } +.day-grid { display: grid; grid-template-columns: repeat(14, 1fr); gap: 6px; margin-top: 12px; } +.day-grid button { aspect-ratio: 1.1; min-width: 0; border: 1px solid var(--line); background: rgba(19,39,32,.65); color: var(--muted); border-radius: 10px; cursor: pointer; font-weight: 800; display: flex; flex-direction: column; align-items: center; justify-content: center; } +.day-grid button small { font-size: .58rem; font-weight: 800; opacity: .6; line-height: 1; margin-bottom: 2px; } +.day-grid button:hover { border-color: rgba(241,201,109,.45); color: var(--ink); } +.day-grid button.selected { color: var(--deep); background: var(--season); border-color: var(--season); box-shadow: 0 6px 18px rgba(0,0,0,.2); } + +.section-nav { position: sticky; top: 0; z-index: 10; display: flex; gap: 8px; background: rgba(12,24,20,.88); backdrop-filter: blur(14px); border-block: 1px solid var(--line); padding: 10px 0; } +.section-nav a { text-decoration: none; padding: 8px 13px; border-radius: 999px; color: var(--muted); font-size: .9rem; font-weight: 750; } +.section-nav a:hover, .section-nav a.active { color: var(--ink); background: var(--panel-2); } + +.content-section { padding: 62px 0; scroll-margin-top: 60px; border-bottom: 1px solid var(--line); } +.section-heading { display: flex; justify-content: space-between; align-items: end; gap: 30px; margin-bottom: 24px; } +.section-heading h2 { margin: 0; font: 700 clamp(2rem, 4vw, 3.1rem)/1 Georgia, serif; letter-spacing: -.025em; } +.section-heading > p { color: var(--muted); max-width: 31rem; margin: 0; text-align: right; font-size: .92rem; } +.today-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; } +.today-card { min-height: 180px; padding: 22px; background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); position: relative; overflow: hidden; } +.today-card::after { content: ""; position: absolute; width: 100px; height: 100px; right: -42px; bottom: -42px; border: 22px solid var(--season-soft); border-radius: 50%; } +.today-card h3 { font: 700 1.35rem/1.1 Georgia, serif; margin: 25px 0 9px; } +.today-card p { color: var(--muted); margin: 0; font-size: .91rem; max-width: 26rem; } +.card-label { display: flex; align-items: center; gap: 8px; color: var(--season); text-transform: uppercase; letter-spacing: .12em; font-size: .68rem; font-weight: 850; } +.card-label span { width: 7px; height: 7px; border-radius: 50%; background: currentColor; } +.today-card.birthday { --season: #f1c96d; } +.today-card.deadline { --season: #f08b5d; } +.today-card.event { --season: #86bcd8; } + +.crop-list { border: 1px solid var(--line); background: var(--panel); border-radius: var(--radius); overflow: hidden; } +.crop-row { display: grid; grid-template-columns: 24px minmax(0, 1fr) 90px 110px; align-items: center; gap: 14px; padding: 16px 18px; border-bottom: 1px solid var(--line); cursor: pointer; transition: background .15s ease; } +.crop-row:last-child { border: 0; } +.crop-row:hover { background: rgba(255,255,255,.025); } +.crop-row input, .bundle-item input { position: absolute; opacity: 0; pointer-events: none; } +.checkmark { width: 22px; height: 22px; border: 1.5px solid #61746a; border-radius: 7px; display: grid; place-items: center; transition: .15s ease; } +.complete .checkmark { background: var(--season); border-color: var(--season); } +.complete .checkmark::after { content: "✓"; color: var(--deep); font-weight: 950; font-size: .86rem; } +.crop-name strong, .bundle-item strong { display: block; font-size: .96rem; } +.crop-name small, .bundle-item small, .grow-time small, .plant-date small { display: block; color: var(--muted); font-size: .76rem; font-weight: 500; margin-top: 2px; } +.grow-time, .plant-date { color: var(--ink); font-weight: 800; font-size: .88rem; text-align: right; } +.plant-date.today { color: var(--accent); } +.crop-row.overdue .plant-date { color: var(--danger); } +.crop-row.complete { opacity: .52; } +.crop-row.complete .crop-name strong { text-decoration: line-through; } + +.bundle-heading { align-items: center; } +.progress-cluster { width: min(290px, 42%); color: var(--muted); text-align: right; font-size: .84rem; } +.progress-track { height: 8px; background: var(--panel-2); border-radius: 99px; margin-top: 9px; overflow: hidden; } +.progress-track i { display: block; height: 100%; width: 0; background: linear-gradient(90deg, var(--season), var(--accent)); border-radius: inherit; transition: width .3s ease; } +.bundle-toolbar { display: inline-flex; gap: 3px; padding: 4px; background: var(--panel); border: 1px solid var(--line); border-radius: 999px; margin-bottom: 30px; } +.room { display: grid; grid-template-columns: 180px minmax(0, 1fr); gap: 24px; margin-bottom: 46px; } +.room-title { display: flex; gap: 13px; align-items: start; position: sticky; top: 74px; height: min-content; } +.room-title > span { width: 40px; height: 40px; display: grid; place-items: center; background: var(--season-soft); color: var(--season); border-radius: 12px; font-size: 1.1rem; } +.room-title h3 { margin: 2px 0 2px; font: 700 1.18rem Georgia, serif; } +.room-title p { margin: 0; color: var(--muted); font-size: .76rem; } +.room-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } +.bundle-card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; } +.bundle-card > header { display: flex; justify-content: space-between; align-items: center; padding: 16px 17px 13px; border-bottom: 1px solid var(--line); } +.bundle-card h4 { margin: 0; font-size: .95rem; } +.bundle-card header small { display: block; color: var(--accent); font-size: .7rem; margin-top: 2px; } +.bundle-card header > span { color: var(--muted); font-size: .8rem; font-weight: 800; } +.bundle-card.complete > header { background: var(--season-soft); } +.bundle-card.complete header > span { color: var(--season); } +.bundle-item { min-height: 59px; display: grid; grid-template-columns: 22px 1fr; gap: 11px; align-items: center; padding: 10px 16px; border-bottom: 1px solid var(--line); cursor: pointer; } +.bundle-item:last-child { border: 0; } +.bundle-item:hover { background: rgba(255,255,255,.025); } +.bundle-item.complete > span:last-child { opacity: .5; } +.bundle-item.complete strong { text-decoration: line-through; } +.empty-state { border: 1px dashed rgba(241,201,109,.28); border-radius: var(--radius); text-align: center; padding: 50px 20px; background: rgba(19,39,32,.55); } +.empty-state > span { color: var(--season); font-size: 2rem; } +.empty-state h3 { margin: 10px 0 5px; font: 700 1.3rem Georgia, serif; } +.empty-state p { margin: 0 auto; color: var(--muted); max-width: 32rem; } + +footer { padding: 28px 0 48px; color: var(--muted); font-size: .76rem; } +footer p { margin: 0; } +footer a { text-underline-offset: 3px; } +.toast { position: fixed; left: 50%; bottom: 24px; z-index: 20; transform: translate(-50%, 30px); opacity: 0; pointer-events: none; width: min(420px, calc(100% - 32px)); padding: 13px 16px; background: var(--ink); color: var(--deep); border-radius: 12px; font-weight: 700; text-align: center; box-shadow: 0 14px 40px rgba(0,0,0,.35); transition: .2s ease; } +.toast.show { opacity: 1; transform: translate(-50%, 0); } + +@media (max-width: 840px) { + .today-grid { grid-template-columns: repeat(2, 1fr); } + .room { grid-template-columns: 1fr; } + .room-title { position: static; } +} + +@media (max-width: 620px) { + .app-shell { width: min(100% - 22px, 1180px); } + .topbar { min-height: 72px; } + .brand small { display: none; } + .sync-status { font-size: .74rem; } + .date-deck { padding-top: 32px; } + .date-heading { align-items: center; } + .date-arrows button { width: 42px; height: 42px; } + .season-tabs { grid-template-columns: repeat(2, 1fr); } + .day-grid { grid-template-columns: repeat(7, 1fr); } + .section-nav { overflow-x: auto; margin-inline: -11px; padding-inline: 11px; } + .section-nav a { white-space: nowrap; } + .content-section { padding: 46px 0; } + .section-heading { align-items: start; flex-direction: column; gap: 10px; } + .section-heading > p { text-align: left; } + .today-grid, .room-grid { grid-template-columns: 1fr; } + .today-card { min-height: 155px; } + .crop-row { grid-template-columns: 24px 1fr 94px; gap: 11px; } + .grow-time { display: none; } + .crop-name small { max-width: 31ch; } + .bundle-heading { align-items: stretch; } + .progress-cluster { width: 100%; text-align: left; } + .bundle-toolbar { display: flex; overflow-x: auto; } + .bundle-toolbar button { flex: 0 0 auto; } +} + +@media (prefers-reduced-motion: reduce) { + html { scroll-behavior: auto; } + *, *::before, *::after { transition-duration: .01ms !important; } +} diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..145c451 --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,35 @@ +import tempfile +import unittest +from pathlib import Path + +import app + + +class StoreTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.store = app.Store(Path(self.temp.name) / "state.sqlite3") + self.store.initialize() + + def tearDown(self): + self.temp.cleanup() + + def test_empty_store(self): + self.assertEqual(self.store.snapshot()["checks"], {}) + self.assertEqual(self.store.snapshot()["revision"], 0) + + def test_shared_check_round_trip(self): + result = self.store.set_check("crop-parsnip", True, "fred") + self.assertEqual(result["revision"], 1) + snapshot = self.store.snapshot() + self.assertTrue(snapshot["checks"]["crop-parsnip"]) + self.assertEqual(snapshot["updatedBy"], "fred") + + self.store.set_check("crop-parsnip", False, "kayla") + snapshot = self.store.snapshot() + self.assertFalse(snapshot["checks"]["crop-parsnip"]) + self.assertEqual(snapshot["revision"], 2) + + +if __name__ == "__main__": + unittest.main()