Initial shared Stardew Valley tracker

This commit is contained in:
Codex 2026-10-04 19:27:36 +01:00
commit a33f7a297d
14 changed files with 1171 additions and 0 deletions

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
__pycache__/
*.pyc
*.sqlite3
result
.direnv/

21
LICENSE Normal file
View file

@ -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.

36
README.md Normal file
View file

@ -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.

231
app.py Normal file
View file

@ -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()

27
flake.lock generated Normal file
View file

@ -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
}

37
flake.nix Normal file
View file

@ -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
'';
});
};
}

51
module.nix Normal file
View file

@ -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" ];
};
};
};
}

26
package.nix Normal file
View file

@ -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";
};
}

292
static/app.js Normal file
View file

@ -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 => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "'": "&#39;", '"': "&quot;" }[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 `<button type="button" class="${day === state.day ? "selected" : ""}" data-day="${day}" aria-label="${seasonNames[state.season]} ${day}, ${weekday(day)}" aria-pressed="${day === state.day}"><small>${weekdays[index % 7].slice(0, 2)}</small>${day}</button>`;
}).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 `<article class="today-card ${kind}"><div class="card-label"><span></span>${escapeHtml(label)}</div><h3>${escapeHtml(title)}</h3><p>${escapeHtml(copy)}</p></article>`;
}
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 `<label class="crop-row ${checked ? "complete" : ""} ${overdue ? "overdue" : ""}">
<input type="checkbox" data-check-id="${key}" ${checked ? "checked" : ""} ${state.saving.has(key) ? "disabled" : ""}>
<span class="checkmark" aria-hidden="true"></span>
<span class="crop-name"><strong>${escapeHtml(crop.name)}</strong><small>${escapeHtml(crop.purpose)}</small></span>
<span class="grow-time">${crop.grow} days<small>to grow</small></span>
<span class="plant-date ${today ? "today" : ""}">${overdue ? "Passed" : `${seasonNames[crop.season]} ${crop.last}`}<small>${overdue ? "for this season" : "plant by"}</small></span>
</label>`;
});
$("#crop-list").innerHTML = rows.length ? rows.join("") : `<div class="empty-state"><span>❄</span><h3>No mandatory field crops</h3><p>Use Winter for the foraging bundle, mines, fishing, and any unfinished animal or artisan goods.</p></div>`;
}
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 `<article class="bundle-card ${progress.complete ? "complete" : ""}">
<header><div><h4>${escapeHtml(group.name)}</h4>${group.choose ? `<small>Choose ${group.required}</small>` : ""}</div><span>${Math.min(progress.done, progress.required)}/${progress.required}</span></header>
<div class="bundle-items">${items.map(entry => {
const checked = Boolean(state.checks[entry.id]);
return `<label class="bundle-item ${checked ? "complete" : ""}"><input type="checkbox" data-check-id="${entry.id}" ${checked ? "checked" : ""} ${state.saving.has(entry.id) ? "disabled" : ""}><span class="checkmark" aria-hidden="true"></span><span><strong>${escapeHtml(entry.label)}</strong><small>${escapeHtml(entry.meta)}</small></span></label>`;
}).join("")}</div>
</article>`;
}).filter(Boolean).join("");
if (!groups) return "";
const roomGroups = room.groups.map(groupProgress);
const roomDone = roomGroups.filter(progress => progress.complete).length;
return `<section class="room"><div class="room-title"><span>${room.icon}</span><div><h3>${escapeHtml(room.room)}</h3><p>${roomDone} of ${room.groups.length} bundles ready</p></div></div><div class="room-grid">${groups}</div></section>`;
}).filter(Boolean).join("");
$("#bundle-list").innerHTML = html || `<div class="empty-state"><span>✓</span><h3>Everything in this view is done</h3><p>Switch filters to review the whole Community Center.</p></div>`;
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();
})();

116
static/data.js Normal file
View file

@ -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"] };
})();

6
static/favicon.svg Normal file
View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="7" fill="#132720"/>
<path d="M7 7h8v8H7zm10 0h8v8h-8zM7 17h8v8H7zm10 0h8v8h-8z" fill="#f1c96d"/>
<path d="M9 9h4v4H9z" fill="#79b56b"/><path d="M19 9h4v4h-4z" fill="#f08b5d"/>
<path d="M9 19h4v4H9z" fill="#c77a48"/><path d="M19 19h4v4h-4z" fill="#b9d7eb"/>
</svg>

After

Width:  |  Height:  |  Size: 366 B

100
static/index.html Normal file
View file

@ -0,0 +1,100 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#132720">
<meta name="description" content="A shared two-year Stardew Valley Community Center planner.">
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="/styles.css">
<title>Valley Yearbook</title>
</head>
<body>
<div class="app-shell">
<header class="topbar">
<a class="brand" href="#today" aria-label="Valley Yearbook home">
<span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i><i></i></span>
<span><strong>Valley Yearbook</strong><small>Two years, one shared plan</small></span>
</a>
<div class="sync-status" id="sync-status" role="status" aria-live="polite">
<span class="sync-dot"></span><span>Connecting…</span>
</div>
</header>
<main>
<section class="date-deck" aria-labelledby="date-title">
<div class="date-heading">
<div>
<p class="eyebrow">Farm calendar</p>
<h1 id="date-title"><span id="season-title">Spring</span> <span id="day-title">1</span></h1>
<p id="weekday-title">Monday · Year 1</p>
</div>
<div class="date-arrows" aria-label="Change day">
<button id="previous-day" type="button" aria-label="Previous day">−</button>
<button id="next-day" type="button" aria-label="Next day">+</button>
</div>
</div>
<div class="year-switch" role="group" aria-label="Year">
<button type="button" data-year="1" aria-pressed="true">Year one</button>
<button type="button" data-year="2" aria-pressed="false">Year two</button>
</div>
<div class="season-tabs" role="tablist" aria-label="Season">
<button type="button" role="tab" data-season="spring" aria-selected="true"><span>✿</span>Spring</button>
<button type="button" role="tab" data-season="summer" aria-selected="false"><span>☀</span>Summer</button>
<button type="button" role="tab" data-season="fall" aria-selected="false"><span>◆</span>Fall</button>
<button type="button" role="tab" data-season="winter" aria-selected="false"><span>✦</span>Winter</button>
</div>
<div class="day-grid" id="day-grid" aria-label="Day of season"></div>
</section>
<nav class="section-nav" aria-label="Planner sections">
<a href="#today" class="active">Today</a>
<a href="#crops">Planting plan</a>
<a href="#bundles">Community Center</a>
</nav>
<section id="today" class="content-section today-section" aria-labelledby="today-heading">
<div class="section-heading">
<div><p class="eyebrow">At a glance</p><h2 id="today-heading">Today in the valley</h2></div>
<p id="today-summary"></p>
</div>
<div id="today-cards" class="today-grid"></div>
</section>
<section id="crops" class="content-section" aria-labelledby="crops-heading">
<div class="section-heading">
<div><p class="eyebrow">No Speed-Gro assumed</p><h2 id="crops-heading">Planting plan</h2></div>
<p>Last safe dates assume the crop is watered on planting day.</p>
</div>
<div id="crop-list" class="crop-list"></div>
</section>
<section id="bundles" class="content-section" aria-labelledby="bundles-heading">
<div class="section-heading bundle-heading">
<div><p class="eyebrow">Standard bundles</p><h2 id="bundles-heading">Community Center</h2></div>
<div class="progress-cluster">
<span id="progress-label">0 of 0 essentials</span>
<div class="progress-track" aria-hidden="true"><i id="progress-bar"></i></div>
</div>
</div>
<div class="bundle-toolbar" role="group" aria-label="Bundle filter">
<button type="button" data-filter="all" aria-pressed="true">All rooms</button>
<button type="button" data-filter="season" aria-pressed="false">Available this season</button>
<button type="button" data-filter="open" aria-pressed="false">Still needed</button>
</div>
<div id="bundle-list" class="bundle-list"></div>
</section>
</main>
<footer>
<p>Unofficial fan-made planner for Stardew Valley 1.6. Calendar and standard-bundle details are based on the <a href="https://stardewvalleywiki.com/Calendar" rel="noreferrer">Stardew Valley Wiki</a>.</p>
</footer>
</div>
<div id="toast" class="toast" role="alert" aria-live="assertive"></div>
<script src="/data.js"></script>
<script src="/app.js"></script>
</body>
</html>

188
static/styles.css Normal file
View file

@ -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; }
}

35
tests/test_app.py Normal file
View file

@ -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()