Record planting day and show harvest dates
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
07057e6c3d
commit
83a9cb6dcc
11 changed files with 282 additions and 44 deletions
|
|
@ -4,6 +4,7 @@ A small, shared tracker for the first two years of Stardew Valley. It combines:
|
||||||
|
|
||||||
- the standard Community Center bundles;
|
- the standard Community Center bundles;
|
||||||
- crop growth times and last safe planting dates;
|
- crop growth times and last safe planting dates;
|
||||||
|
- planted crops with their planting day and expected harvest date;
|
||||||
- birthday previews and gift tips, festivals, seasonal events, and weekly reminders; and
|
- birthday previews and gift tips, festivals, seasonal events, and weekly reminders; and
|
||||||
- one server-side checklist that stays in sync across phones and computers.
|
- one server-side checklist that stays in sync across phones and computers.
|
||||||
|
|
||||||
|
|
|
||||||
93
app.py
93
app.py
|
|
@ -21,12 +21,25 @@ APP_DIR = Path(__file__).resolve().parent
|
||||||
STATIC_DIR = APP_DIR / "static"
|
STATIC_DIR = APP_DIR / "static"
|
||||||
ITEM_ID = re.compile(r"^[a-z0-9][a-z0-9-]{0,79}$")
|
ITEM_ID = re.compile(r"^[a-z0-9][a-z0-9-]{0,79}$")
|
||||||
MAX_BODY = 2048
|
MAX_BODY = 2048
|
||||||
|
SEASONS = ("spring", "summer", "fall", "winter")
|
||||||
|
|
||||||
|
|
||||||
def utc_now() -> str:
|
def utc_now() -> str:
|
||||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
|
||||||
|
def valid_game_date(value: object) -> bool:
|
||||||
|
def whole(number: object, low: int, high: int) -> bool:
|
||||||
|
return isinstance(number, int) and not isinstance(number, bool) and low <= number <= high
|
||||||
|
|
||||||
|
return (
|
||||||
|
isinstance(value, dict)
|
||||||
|
and whole(value.get("year"), 1, 2)
|
||||||
|
and value.get("season") in SEASONS
|
||||||
|
and whole(value.get("day"), 1, 28)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Store:
|
class Store:
|
||||||
def __init__(self, path: str | Path):
|
def __init__(self, path: str | Path):
|
||||||
self.path = str(path)
|
self.path = str(path)
|
||||||
|
|
@ -53,8 +66,32 @@ class Store:
|
||||||
value TEXT NOT NULL
|
value TEXT NOT NULL
|
||||||
);
|
);
|
||||||
INSERT OR IGNORE INTO metadata (key, value) VALUES ('revision', '0');
|
INSERT OR IGNORE INTO metadata (key, value) VALUES ('revision', '0');
|
||||||
|
-- A NULL date means the crop was planted before dates were recorded.
|
||||||
|
CREATE TABLE IF NOT EXISTS plantings (
|
||||||
|
item_id TEXT PRIMARY KEY,
|
||||||
|
year INTEGER,
|
||||||
|
season TEXT,
|
||||||
|
day INTEGER,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
updated_by TEXT NOT NULL
|
||||||
|
);
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
# Version 1.4 stored planting as an undated "planted-<crop>" check.
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
INSERT OR IGNORE INTO plantings (item_id, updated_at, updated_by)
|
||||||
|
SELECT substr(item_id, 9), updated_at, updated_by FROM checks
|
||||||
|
WHERE item_id LIKE 'planted-%' AND checked = 1
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
if db.execute("DELETE FROM checks WHERE item_id LIKE 'planted-%'").rowcount:
|
||||||
|
self._bump_revision(db)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _bump_revision(db: sqlite3.Connection) -> int:
|
||||||
|
db.execute("UPDATE metadata SET value = CAST(value AS INTEGER) + 1 WHERE key = 'revision'")
|
||||||
|
return int(db.execute("SELECT value FROM metadata WHERE key = 'revision'").fetchone()[0])
|
||||||
|
|
||||||
def snapshot(self) -> dict:
|
def snapshot(self) -> dict:
|
||||||
with closing(self.connect()) as db, db:
|
with closing(self.connect()) as db, db:
|
||||||
|
|
@ -67,11 +104,47 @@ class Store:
|
||||||
latest_row = db.execute(
|
latest_row = db.execute(
|
||||||
"SELECT updated_at, updated_by FROM checks ORDER BY updated_at DESC, rowid DESC LIMIT 1"
|
"SELECT updated_at, updated_by FROM checks ORDER BY updated_at DESC, rowid DESC LIMIT 1"
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
plantings = db.execute(
|
||||||
|
"SELECT item_id, year, season, day FROM plantings ORDER BY item_id"
|
||||||
|
).fetchall()
|
||||||
return {
|
return {
|
||||||
"revision": revision,
|
"revision": revision,
|
||||||
"updatedAt": latest_row["updated_at"] if latest_row else None,
|
"updatedAt": latest_row["updated_at"] if latest_row else None,
|
||||||
"updatedBy": latest_row["updated_by"] 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},
|
"checks": {row["item_id"]: bool(row["checked"]) for row in rows},
|
||||||
|
"plantings": {
|
||||||
|
row["item_id"]: {"year": row["year"], "season": row["season"], "day": row["day"]}
|
||||||
|
for row in plantings
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def set_planting(self, item_id: str, planted: dict | None, user: str) -> dict:
|
||||||
|
changed_at = utc_now()
|
||||||
|
with closing(self.connect()) as db, db:
|
||||||
|
db.execute("BEGIN IMMEDIATE")
|
||||||
|
if planted is None:
|
||||||
|
db.execute("DELETE FROM plantings WHERE item_id = ?", (item_id,))
|
||||||
|
else:
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO plantings (item_id, year, season, day, updated_at, updated_by)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(item_id) DO UPDATE SET
|
||||||
|
year = excluded.year,
|
||||||
|
season = excluded.season,
|
||||||
|
day = excluded.day,
|
||||||
|
updated_at = excluded.updated_at,
|
||||||
|
updated_by = excluded.updated_by
|
||||||
|
""",
|
||||||
|
(item_id, planted["year"], planted["season"], planted["day"], changed_at, user),
|
||||||
|
)
|
||||||
|
revision = self._bump_revision(db)
|
||||||
|
return {
|
||||||
|
"itemId": item_id,
|
||||||
|
"planted": planted,
|
||||||
|
"revision": revision,
|
||||||
|
"updatedAt": changed_at,
|
||||||
|
"updatedBy": user,
|
||||||
}
|
}
|
||||||
|
|
||||||
def set_check(self, item_id: str, checked: bool, user: str) -> dict:
|
def set_check(self, item_id: str, checked: bool, user: str) -> dict:
|
||||||
|
|
@ -89,12 +162,7 @@ class Store:
|
||||||
""",
|
""",
|
||||||
(item_id, int(checked), changed_at, user),
|
(item_id, int(checked), changed_at, user),
|
||||||
)
|
)
|
||||||
db.execute(
|
revision = self._bump_revision(db)
|
||||||
"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 {
|
return {
|
||||||
"itemId": item_id,
|
"itemId": item_id,
|
||||||
"checked": checked,
|
"checked": checked,
|
||||||
|
|
@ -153,7 +221,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
|
|
||||||
def do_POST(self) -> None:
|
def do_POST(self) -> None:
|
||||||
path = urlparse(self.path).path
|
path = urlparse(self.path).path
|
||||||
if path != "/api/checks":
|
if path not in ("/api/checks", "/api/plantings"):
|
||||||
self.error_json("Not found", HTTPStatus.NOT_FOUND)
|
self.error_json("Not found", HTTPStatus.NOT_FOUND)
|
||||||
return
|
return
|
||||||
if self.headers.get_content_type() != "application/json":
|
if self.headers.get_content_type() != "application/json":
|
||||||
|
|
@ -173,10 +241,19 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
self.error_json("Invalid JSON", HTTPStatus.BAD_REQUEST)
|
self.error_json("Invalid JSON", HTTPStatus.BAD_REQUEST)
|
||||||
return
|
return
|
||||||
item_id = payload.get("itemId") if isinstance(payload, dict) else None
|
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):
|
if not isinstance(item_id, str) or not ITEM_ID.fullmatch(item_id):
|
||||||
self.error_json("Invalid itemId", HTTPStatus.BAD_REQUEST)
|
self.error_json("Invalid itemId", HTTPStatus.BAD_REQUEST)
|
||||||
return
|
return
|
||||||
|
if path == "/api/plantings":
|
||||||
|
planted = payload.get("planted")
|
||||||
|
if planted is not None and not valid_game_date(planted):
|
||||||
|
self.error_json("planted must be null or a {year, season, day} date", HTTPStatus.BAD_REQUEST)
|
||||||
|
return
|
||||||
|
if planted is not None:
|
||||||
|
planted = {key: planted[key] for key in ("year", "season", "day")}
|
||||||
|
self.json_response(self.server.store.set_planting(item_id, planted, self.authenticated_user()))
|
||||||
|
return
|
||||||
|
checked = payload.get("checked")
|
||||||
if not isinstance(checked, bool):
|
if not isinstance(checked, bool):
|
||||||
self.error_json("checked must be boolean", HTTPStatus.BAD_REQUEST)
|
self.error_json("checked must be boolean", HTTPStatus.BAD_REQUEST)
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
stdenvNoCC.mkDerivation {
|
stdenvNoCC.mkDerivation {
|
||||||
pname = "stardew-tracker";
|
pname = "stardew-tracker";
|
||||||
version = "1.4.0";
|
version = "1.5.0";
|
||||||
src = ./.;
|
src = ./.;
|
||||||
|
|
||||||
nativeBuildInputs = [ makeWrapper ];
|
nativeBuildInputs = [ makeWrapper ];
|
||||||
|
|
|
||||||
111
static/app.js
111
static/app.js
|
|
@ -11,18 +11,30 @@
|
||||||
day: Number(localStorage.getItem("valley-day")) || 1,
|
day: Number(localStorage.getItem("valley-day")) || 1,
|
||||||
filter: "all",
|
filter: "all",
|
||||||
checks: {},
|
checks: {},
|
||||||
|
plantings: {},
|
||||||
revision: -1,
|
revision: -1,
|
||||||
saving: new Set()
|
saving: new Set(),
|
||||||
|
savingPlantings: new Set()
|
||||||
};
|
};
|
||||||
|
|
||||||
const $ = (selector, root = document) => root.querySelector(selector);
|
const $ = (selector, root = document) => root.querySelector(selector);
|
||||||
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
|
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
|
||||||
const escapeHtml = (value) => String(value).replace(/[&<>'"]/g, char => ({ "&": "&", "<": "<", ">": ">", "'": "'", '"': """ }[char]));
|
const escapeHtml = (value) => String(value).replace(/[&<>'"]/g, char => ({ "&": "&", "<": "<", ">": ">", "'": "'", '"': """ }[char]));
|
||||||
const itemKey = crop => crop.sharedId || crop.id;
|
const itemKey = crop => crop.sharedId || crop.id;
|
||||||
// Planting is tracked as its own shared flag so "on the way" never counts
|
const cropFor = key => data.crops.find(crop => itemKey(crop) === key);
|
||||||
// toward bundle completion.
|
// Plantings are stored apart from checks so "on the way" never counts toward
|
||||||
const plantedId = key => `planted-${key}`;
|
// bundle completion. Crops planted before 1.5 have no recorded day.
|
||||||
const isPlanted = key => !state.checks[key] && Boolean(state.checks[plantedId(key)]);
|
const plantingFor = key => (!state.checks[key] && state.plantings[key]) || null;
|
||||||
|
const selectedDate = () => ({ year: state.year, season: state.season, day: state.day });
|
||||||
|
const formatDate = date => `${seasonNames[date.season]} ${date.day}${date.year !== state.year ? ` (Y${date.year})` : ""}`;
|
||||||
|
|
||||||
|
function harvestInfo(key, grow) {
|
||||||
|
const planted = plantingFor(key);
|
||||||
|
if (!planted?.day) return null;
|
||||||
|
const date = calendar.harvestDate(planted, grow);
|
||||||
|
const survives = date.season === planted.season || data.crops.some(crop => itemKey(crop) === key && crop.season === date.season);
|
||||||
|
return { planted, date, survives, daysLeft: calendar.daysBetween(selectedDate(), date) };
|
||||||
|
}
|
||||||
|
|
||||||
function saveDate() {
|
function saveDate() {
|
||||||
localStorage.setItem("valley-year", state.year);
|
localStorage.setItem("valley-year", state.year);
|
||||||
|
|
@ -130,8 +142,15 @@
|
||||||
if (birthday) cards.push(card("birthday", "Birthday", birthday, "A birthday gift gives 8× the usual friendship effect.", dailyId("birthday", birthday), giftGuide(birthday)));
|
if (birthday) cards.push(card("birthday", "Birthday", birthday, "A birthday gift gives 8× the usual friendship effect.", dailyId("birthday", birthday), giftGuide(birthday)));
|
||||||
if (event) cards.push(card("event", "Event", event[0], event[1], dailyId("event", event[0])));
|
if (event) cards.push(card("event", "Event", event[0], event[1], dailyId("event", event[0])));
|
||||||
reminders().forEach(([title, copy]) => cards.push(card("reminder", "Reminder", title, copy, dailyId("reminder", title))));
|
reminders().forEach(([title, copy]) => cards.push(card("reminder", "Reminder", title, copy, dailyId("reminder", title))));
|
||||||
|
const harvestKeys = new Set();
|
||||||
|
data.crops.forEach(crop => {
|
||||||
|
const key = itemKey(crop);
|
||||||
|
const harvest = harvestKeys.has(key) ? null : harvestInfo(key, crop.grow);
|
||||||
|
harvestKeys.add(key);
|
||||||
|
if (harvest?.survives && harvest.daysLeft === 0) cards.push(card("harvest", "Harvest today", crop.name, `Planted ${formatDate(harvest.planted)} · ${crop.purpose}`));
|
||||||
|
});
|
||||||
cropDeadlineCards().forEach(crop => {
|
cropDeadlineCards().forEach(crop => {
|
||||||
const planted = isPlanted(itemKey(crop));
|
const planted = Boolean(plantingFor(itemKey(crop)));
|
||||||
cards.push(card(planted ? "deadline planted" : "deadline", planted ? "Planted · on the way" : "Plant by today", crop.name, crop.purpose, itemKey(crop)));
|
cards.push(card(planted ? "deadline planted" : "deadline", planted ? "Planted · on the way" : "Plant by today", crop.name, crop.purpose, itemKey(crop)));
|
||||||
});
|
});
|
||||||
if (!cards.length) cards.push(card("quiet", "Open day", "Make your own luck", "No fixed birthdays, festivals, or last planting dates today."));
|
if (!cards.length) cards.push(card("quiet", "Open day", "Make your own luck", "No fixed birthdays, festivals, or last planting dates today."));
|
||||||
|
|
@ -145,20 +164,32 @@
|
||||||
const rows = crops.map(crop => {
|
const rows = crops.map(crop => {
|
||||||
const key = itemKey(crop);
|
const key = itemKey(crop);
|
||||||
const checked = Boolean(state.checks[key]);
|
const checked = Boolean(state.checks[key]);
|
||||||
const planted = isPlanted(key);
|
const planted = Boolean(plantingFor(key));
|
||||||
|
const harvest = harvestInfo(key, crop.grow);
|
||||||
const overdue = state.day > crop.last && !checked && !planted;
|
const overdue = state.day > crop.last && !checked && !planted;
|
||||||
const today = state.day === crop.last && !checked && !planted;
|
const today = state.day === crop.last && !checked && !planted;
|
||||||
const plantButton = checked ? "" : `<button type="button" class="plant-toggle" data-plant-id="${plantedId(key)}" aria-pressed="${planted}" aria-label="${planted ? "Unmark" : "Mark"} ${escapeHtml(crop.name)} as planted" ${state.saving.has(plantedId(key)) ? "disabled" : ""}><span aria-hidden="true">🌱</span>${planted ? "Planted" : "Plant"}</button>`;
|
const plantButton = checked ? "" : `<button type="button" class="plant-toggle" data-plant-id="${key}" aria-pressed="${planted}" aria-label="${planted ? `Unmark ${escapeHtml(crop.name)} as planted` : `Mark ${escapeHtml(crop.name)} planted on ${formatDate(selectedDate())}`}" ${state.savingPlantings.has(key) ? "disabled" : ""}><span aria-hidden="true">🌱</span>${planted ? "Planted" : "Plant"}</button>`;
|
||||||
const date = planted
|
let growTime = `${crop.grow} days<small>to grow</small>`;
|
||||||
? `On the way<small>planted</small>`
|
let date = `${overdue ? "Passed" : `${seasonNames[crop.season]} ${crop.last}`}<small>${overdue ? "for this season" : "plant by"}</small>`;
|
||||||
: `${overdue ? "Passed" : `${seasonNames[crop.season]} ${crop.last}`}<small>${overdue ? "for this season" : "plant by"}</small>`;
|
let dateClass = today ? "today" : "";
|
||||||
|
if (planted && !harvest) date = `On the way<small>planting day unknown</small>`;
|
||||||
|
else if (harvest && !harvest.survives) {
|
||||||
|
date = `Won’t ripen<small>season ends first</small>`;
|
||||||
|
dateClass = "wilts";
|
||||||
|
} else if (harvest) {
|
||||||
|
const ready = harvest.daysLeft <= 0;
|
||||||
|
growTime = ready ? `Ready<small>to harvest</small>` : `${harvest.daysLeft} ${harvest.daysLeft === 1 ? "day" : "days"}<small>to harvest</small>`;
|
||||||
|
date = `${harvest.daysLeft === 0 ? "Today" : ready ? "Ready" : formatDate(harvest.date)}<small>${harvest.daysLeft < 0 ? `since ${formatDate(harvest.date)}` : "harvest"}</small>`;
|
||||||
|
if (ready) dateClass = "ready";
|
||||||
|
}
|
||||||
|
const purpose = harvest ? `Planted ${formatDate(harvest.planted)} · ${crop.purpose}` : crop.purpose;
|
||||||
return `<div class="crop-row ${checked ? "complete" : ""} ${overdue ? "overdue" : ""} ${planted ? "planted" : ""}">
|
return `<div class="crop-row ${checked ? "complete" : ""} ${overdue ? "overdue" : ""} ${planted ? "planted" : ""}">
|
||||||
<input type="checkbox" data-check-id="${key}" aria-label="Mark ${escapeHtml(crop.name)} complete" ${checked ? "checked" : ""} ${state.saving.has(key) ? "disabled" : ""}>
|
<input type="checkbox" data-check-id="${key}" aria-label="Mark ${escapeHtml(crop.name)} complete" ${checked ? "checked" : ""} ${state.saving.has(key) ? "disabled" : ""}>
|
||||||
<span class="checkmark" aria-hidden="true"></span>
|
<span class="checkmark" aria-hidden="true"></span>
|
||||||
<span class="crop-name"><strong>${escapeHtml(crop.name)}</strong><small>${escapeHtml(crop.purpose)}</small></span>
|
<span class="crop-name"><strong>${escapeHtml(crop.name)}</strong><small>${escapeHtml(purpose)}</small></span>
|
||||||
<span class="plant-slot">${plantButton}</span>
|
<span class="plant-slot">${plantButton}</span>
|
||||||
<span class="grow-time">${crop.grow} days<small>to grow</small></span>
|
<span class="grow-time">${growTime}</span>
|
||||||
<span class="plant-date ${today ? "today" : ""}">${date}</span>
|
<span class="plant-date ${dateClass}">${date}</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
});
|
});
|
||||||
$("#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>`;
|
$("#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>`;
|
||||||
|
|
@ -210,7 +241,12 @@
|
||||||
function statusBadges(status, entry) {
|
function statusBadges(status, entry) {
|
||||||
if (status.checked) return "";
|
if (status.checked) return "";
|
||||||
const badges = [];
|
const badges = [];
|
||||||
if (isPlanted(entry.id)) badges.push(`<span class="status-badge planted">🌱 Planted</span>`);
|
if (plantingFor(entry.id)) {
|
||||||
|
const crop = cropFor(entry.id);
|
||||||
|
const harvest = crop ? harvestInfo(entry.id, crop.grow) : null;
|
||||||
|
const label = !harvest?.survives ? "Planted" : harvest.daysLeft <= 0 ? "Ready to harvest" : `Ready ${formatDate(harvest.date)}`;
|
||||||
|
badges.push(`<span class="status-badge planted">🌱 ${label}</span>`);
|
||||||
|
}
|
||||||
if (!status.needed) badges.push(`<span class="status-badge optional">Bundle ready</span>`);
|
if (!status.needed) badges.push(`<span class="status-badge optional">Bundle ready</span>`);
|
||||||
else if (status.seasonExclusive) badges.push(`<span class="status-badge exclusive">Only ${seasonNames[state.season]}</span>`);
|
else if (status.seasonExclusive) badges.push(`<span class="status-badge exclusive">Only ${seasonNames[state.season]}</span>`);
|
||||||
else if (status.inSeason) badges.push(`<span class="status-badge in-season">In season</span>`);
|
else if (status.inSeason) badges.push(`<span class="status-badge in-season">In season</span>`);
|
||||||
|
|
@ -231,7 +267,7 @@
|
||||||
<div class="bundle-items">${items.map(entry => {
|
<div class="bundle-items">${items.map(entry => {
|
||||||
const checked = Boolean(state.checks[entry.id]);
|
const checked = Boolean(state.checks[entry.id]);
|
||||||
const status = entryStatus(entry, group);
|
const status = entryStatus(entry, group);
|
||||||
const classes = [checked ? "complete" : "", status.needed ? "needed" : "optional", status.needed && status.availableNow ? "available-now" : "", status.seasonExclusive ? "season-exclusive" : "", status.weather ? "weather-specific" : "", isPlanted(entry.id) ? "planted" : ""].filter(Boolean).join(" ");
|
const classes = [checked ? "complete" : "", status.needed ? "needed" : "optional", status.needed && status.availableNow ? "available-now" : "", status.seasonExclusive ? "season-exclusive" : "", status.weather ? "weather-specific" : "", plantingFor(entry.id) ? "planted" : ""].filter(Boolean).join(" ");
|
||||||
return `<label class="bundle-item ${classes}"><input type="checkbox" data-check-id="${entry.id}" aria-label="Mark ${escapeHtml(entry.label)} complete" ${checked ? "checked" : ""} ${state.saving.has(entry.id) ? "disabled" : ""}><span class="checkmark" aria-hidden="true"></span><span class="item-copy"><strong>${escapeHtml(entry.label)}</strong><small>${escapeHtml(entry.meta)}</small>${statusBadges(status, entry)}</span></label>`;
|
return `<label class="bundle-item ${classes}"><input type="checkbox" data-check-id="${entry.id}" aria-label="Mark ${escapeHtml(entry.label)} complete" ${checked ? "checked" : ""} ${state.saving.has(entry.id) ? "disabled" : ""}><span class="checkmark" aria-hidden="true"></span><span class="item-copy"><strong>${escapeHtml(entry.label)}</strong><small>${escapeHtml(entry.meta)}</small>${statusBadges(status, entry)}</span></label>`;
|
||||||
}).join("")}</div>
|
}).join("")}</div>
|
||||||
</article>`;
|
</article>`;
|
||||||
|
|
@ -277,9 +313,10 @@
|
||||||
const response = await fetch("/api/state", { cache: "no-store" });
|
const response = await fetch("/api/state", { cache: "no-store" });
|
||||||
if (!response.ok) throw new Error(`State request failed (${response.status})`);
|
if (!response.ok) throw new Error(`State request failed (${response.status})`);
|
||||||
const snapshot = await response.json();
|
const snapshot = await response.json();
|
||||||
const merged = sync.mergeSnapshot(state.checks, state.saving, state.revision, snapshot);
|
const merged = sync.mergeSnapshot(state.checks, state.saving, state.revision, snapshot, state.plantings, state.savingPlantings);
|
||||||
if (merged.applied) {
|
if (merged.applied) {
|
||||||
state.checks = merged.checks;
|
state.checks = merged.checks;
|
||||||
|
state.plantings = merged.plantings;
|
||||||
state.revision = merged.revision;
|
state.revision = merged.revision;
|
||||||
renderToday();
|
renderToday();
|
||||||
renderCrops();
|
renderCrops();
|
||||||
|
|
@ -326,6 +363,42 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updatePlanting(itemId, planted) {
|
||||||
|
const previous = state.plantings[itemId];
|
||||||
|
if (planted) state.plantings[itemId] = planted;
|
||||||
|
else delete state.plantings[itemId];
|
||||||
|
state.savingPlantings.add(itemId);
|
||||||
|
renderToday();
|
||||||
|
renderCrops();
|
||||||
|
renderBundles();
|
||||||
|
setSyncStatus("loading", "Saving…");
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/plantings", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ itemId, planted })
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`Save failed (${response.status})`);
|
||||||
|
const result = await response.json();
|
||||||
|
const confirmed = sync.confirmPlanting(state.plantings, state.revision, result);
|
||||||
|
state.plantings = confirmed.plantings;
|
||||||
|
state.revision = confirmed.revision;
|
||||||
|
setSyncStatus("ready", "Shared progress synced");
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
if (previous) state.plantings[itemId] = previous;
|
||||||
|
else delete state.plantings[itemId];
|
||||||
|
setSyncStatus("error", "Couldn’t save");
|
||||||
|
toast("That change wasn’t saved. Your previous shared state is restored.");
|
||||||
|
void loadState({ quiet: true });
|
||||||
|
} finally {
|
||||||
|
state.savingPlantings.delete(itemId);
|
||||||
|
renderToday();
|
||||||
|
renderCrops();
|
||||||
|
renderBundles();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function installWebMcp() {
|
function installWebMcp() {
|
||||||
const context = document.modelContext;
|
const context = document.modelContext;
|
||||||
if (!context?.registerTool) return;
|
if (!context?.registerTool) return;
|
||||||
|
|
@ -336,7 +409,7 @@
|
||||||
description: "Read the currently selected game date and shared Community Center checklist progress.",
|
description: "Read the currently selected game date and shared Community Center checklist progress.",
|
||||||
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
||||||
annotations: { readOnlyHint: true, untrustedContentHint: 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]) })
|
execute: () => ({ year: state.year, season: state.season, day: state.day, progress: bundleTotals(), completedItemIds: Object.keys(state.checks).filter(id => state.checks[id]), plantings: state.plantings })
|
||||||
})).catch(console.error);
|
})).catch(console.error);
|
||||||
void Promise.resolve(context.registerTool({
|
void Promise.resolve(context.registerTool({
|
||||||
name: "set_valley_checklist_item",
|
name: "set_valley_checklist_item",
|
||||||
|
|
@ -366,7 +439,7 @@
|
||||||
renderBundles();
|
renderBundles();
|
||||||
}
|
}
|
||||||
const plantButton = event.target.closest("button[data-plant-id]");
|
const plantButton = event.target.closest("button[data-plant-id]");
|
||||||
if (plantButton && !plantButton.disabled) updateCheck(plantButton.dataset.plantId, !state.checks[plantButton.dataset.plantId]);
|
if (plantButton && !plantButton.disabled) updatePlanting(plantButton.dataset.plantId, plantingFor(plantButton.dataset.plantId) ? null : selectedDate());
|
||||||
const navLink = event.target.closest(".section-nav a");
|
const navLink = event.target.closest(".section-nav a");
|
||||||
if (navLink) {
|
if (navLink) {
|
||||||
$$(".section-nav a").forEach(link => link.classList.toggle("active", link === navLink));
|
$$(".section-nav a").forEach(link => link.classList.toggle("active", link === navLink));
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,29 @@
|
||||||
const api = factory();
|
const api = factory();
|
||||||
if (typeof module === "object" && module.exports) module.exports = api;
|
if (typeof module === "object" && module.exports) module.exports = api;
|
||||||
if (root) root.ValleyCalendar = api;
|
if (root) root.ValleyCalendar = api;
|
||||||
})(typeof window === "undefined" ? globalThis : window, () => ({
|
})(typeof window === "undefined" ? globalThis : window, () => {
|
||||||
birthdayOn(birthdays, season, day, year) {
|
const seasons = ["spring", "summer", "fall", "winter"];
|
||||||
const entry = birthdays?.[season]?.[day];
|
// Days since Spring 1, Year 1 (which is day 1).
|
||||||
if (!entry || (entry[1] && entry[1] !== year)) return null;
|
const dayNumber = ({ year, season, day }) => (year - 1) * 112 + seasons.indexOf(season) * 28 + day;
|
||||||
return entry[0];
|
const fromDayNumber = number => {
|
||||||
}
|
const offset = number - 1;
|
||||||
}));
|
return { year: Math.floor(offset / 112) + 1, season: seasons[Math.floor(offset % 112 / 28)], day: offset % 28 + 1 };
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
birthdayOn(birthdays, season, day, year) {
|
||||||
|
const entry = birthdays?.[season]?.[day];
|
||||||
|
if (!entry || (entry[1] && entry[1] !== year)) return null;
|
||||||
|
return entry[0];
|
||||||
|
},
|
||||||
|
|
||||||
|
// Crops are harvestable `growDays` after planting when watered daily.
|
||||||
|
harvestDate(planted, growDays) {
|
||||||
|
return fromDayNumber(dayNumber(planted) + growDays);
|
||||||
|
},
|
||||||
|
|
||||||
|
daysBetween(from, to) {
|
||||||
|
return dayNumber(to) - dayNumber(from);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
<meta name="theme-color" content="#132720">
|
<meta name="theme-color" content="#132720">
|
||||||
<meta name="description" content="A shared two-year Stardew Valley Community Center planner.">
|
<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="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
<link rel="stylesheet" href="/styles.css?v=1.4.0">
|
<link rel="stylesheet" href="/styles.css?v=1.5.0">
|
||||||
<title>Valley Yearbook</title>
|
<title>Valley Yearbook</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -115,10 +115,10 @@
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
<div id="toast" class="toast" role="alert" aria-live="assertive"></div>
|
<div id="toast" class="toast" role="alert" aria-live="assertive"></div>
|
||||||
<script src="/data.js?v=1.4.0"></script>
|
<script src="/data.js?v=1.5.0"></script>
|
||||||
<script src="/sync.js?v=1.4.0"></script>
|
<script src="/sync.js?v=1.5.0"></script>
|
||||||
<script src="/availability.js?v=1.4.0"></script>
|
<script src="/availability.js?v=1.5.0"></script>
|
||||||
<script src="/calendar.js?v=1.4.0"></script>
|
<script src="/calendar.js?v=1.5.0"></script>
|
||||||
<script src="/app.js?v=1.4.0"></script>
|
<script src="/app.js?v=1.5.0"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -139,6 +139,9 @@ label.today-card:hover { border-color: color-mix(in srgb, var(--season) 55%, tra
|
||||||
.plant-toggle:disabled { cursor: wait; opacity: .6; }
|
.plant-toggle:disabled { cursor: wait; opacity: .6; }
|
||||||
.crop-row.planted { box-shadow: inset 3px 0 0 #79b56b; }
|
.crop-row.planted { box-shadow: inset 3px 0 0 #79b56b; }
|
||||||
.crop-row.planted .plant-date { color: #a9dfa4; }
|
.crop-row.planted .plant-date { color: #a9dfa4; }
|
||||||
|
.crop-row.planted .plant-date.ready { color: var(--accent); }
|
||||||
|
.crop-row.planted .plant-date.wilts { color: var(--danger); }
|
||||||
|
.today-card.harvest { --season: #79b56b; }
|
||||||
.today-card.deadline.planted { --season: #79b56b; }
|
.today-card.deadline.planted { --season: #79b56b; }
|
||||||
.status-badge.planted { color: #a9dfa4; background: rgba(121,181,107,.18); }
|
.status-badge.planted { color: #a9dfa4; background: rgba(121,181,107,.18); }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,9 @@
|
||||||
if (typeof module === "object" && module.exports) module.exports = api;
|
if (typeof module === "object" && module.exports) module.exports = api;
|
||||||
if (root) root.ValleySync = api;
|
if (root) root.ValleySync = api;
|
||||||
})(typeof window === "undefined" ? globalThis : window, () => ({
|
})(typeof window === "undefined" ? globalThis : window, () => ({
|
||||||
mergeSnapshot(localChecks, savingIds, currentRevision, snapshot) {
|
mergeSnapshot(localChecks, savingIds, currentRevision, snapshot, localPlantings = {}, savingPlantings = new Set()) {
|
||||||
if (!snapshot || !Number.isInteger(snapshot.revision) || snapshot.revision <= currentRevision) {
|
if (!snapshot || !Number.isInteger(snapshot.revision) || snapshot.revision <= currentRevision) {
|
||||||
return { applied: false, revision: currentRevision, checks: localChecks };
|
return { applied: false, revision: currentRevision, checks: localChecks, plantings: localPlantings };
|
||||||
}
|
}
|
||||||
|
|
||||||
const checks = { ...(snapshot.checks || {}) };
|
const checks = { ...(snapshot.checks || {}) };
|
||||||
|
|
@ -14,7 +14,12 @@
|
||||||
checks[itemId] = Boolean(localChecks[itemId]);
|
checks[itemId] = Boolean(localChecks[itemId]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { applied: true, revision: snapshot.revision, checks };
|
const plantings = { ...(snapshot.plantings || {}) };
|
||||||
|
for (const itemId of savingPlantings) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(localPlantings, itemId)) plantings[itemId] = localPlantings[itemId];
|
||||||
|
else delete plantings[itemId];
|
||||||
|
}
|
||||||
|
return { applied: true, revision: snapshot.revision, checks, plantings };
|
||||||
},
|
},
|
||||||
|
|
||||||
confirmWrite(localChecks, currentRevision, result) {
|
confirmWrite(localChecks, currentRevision, result) {
|
||||||
|
|
@ -22,5 +27,12 @@
|
||||||
checks: { ...localChecks, [result.itemId]: Boolean(result.checked) },
|
checks: { ...localChecks, [result.itemId]: Boolean(result.checked) },
|
||||||
revision: Math.max(currentRevision, result.revision)
|
revision: Math.max(currentRevision, result.revision)
|
||||||
};
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
confirmPlanting(localPlantings, currentRevision, result) {
|
||||||
|
const plantings = { ...localPlantings };
|
||||||
|
if (result.planted) plantings[result.itemId] = result.planted;
|
||||||
|
else delete plantings[result.itemId];
|
||||||
|
return { plantings, revision: Math.max(currentRevision, result.revision) };
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,34 @@ class StoreTests(unittest.TestCase):
|
||||||
self.assertEqual(len(snapshot["checks"]), 20)
|
self.assertEqual(len(snapshot["checks"]), 20)
|
||||||
self.assertTrue(all(snapshot["checks"].values()))
|
self.assertTrue(all(snapshot["checks"].values()))
|
||||||
|
|
||||||
|
def test_planting_round_trip(self):
|
||||||
|
spring_three = {"year": 1, "season": "spring", "day": 3}
|
||||||
|
result = self.store.set_planting("crop-parsnip", spring_three, "fred")
|
||||||
|
self.assertEqual(result["revision"], 1)
|
||||||
|
self.assertEqual(self.store.snapshot()["plantings"], {"crop-parsnip": spring_three})
|
||||||
|
|
||||||
|
self.store.set_planting("crop-parsnip", None, "fred")
|
||||||
|
snapshot = self.store.snapshot()
|
||||||
|
self.assertEqual(snapshot["plantings"], {})
|
||||||
|
self.assertEqual(snapshot["revision"], 2)
|
||||||
|
|
||||||
|
def test_undated_planted_checks_migrate_to_plantings(self):
|
||||||
|
self.store.set_check("planted-crop-parsnip", True, "fred")
|
||||||
|
self.store.set_check("planted-crop-potato", False, "fred")
|
||||||
|
self.store.initialize()
|
||||||
|
snapshot = self.store.snapshot()
|
||||||
|
self.assertEqual(snapshot["checks"], {})
|
||||||
|
self.assertEqual(
|
||||||
|
snapshot["plantings"], {"crop-parsnip": {"year": None, "season": None, "day": None}}
|
||||||
|
)
|
||||||
|
self.assertEqual(snapshot["revision"], 3)
|
||||||
|
|
||||||
|
def test_game_date_validation(self):
|
||||||
|
self.assertTrue(app.valid_game_date({"year": 2, "season": "winter", "day": 28}))
|
||||||
|
self.assertFalse(app.valid_game_date({"year": 1, "season": "spring", "day": 29}))
|
||||||
|
self.assertFalse(app.valid_game_date({"year": True, "season": "spring", "day": 1}))
|
||||||
|
self.assertFalse(app.valid_game_date({"year": 1, "season": "monsoon", "day": 1}))
|
||||||
|
|
||||||
|
|
||||||
class StaticInteractionTests(unittest.TestCase):
|
class StaticInteractionTests(unittest.TestCase):
|
||||||
def test_native_checkboxes_cover_each_tickable_row(self):
|
def test_native_checkboxes_cover_each_tickable_row(self):
|
||||||
|
|
|
||||||
|
|
@ -12,3 +12,13 @@ assert.equal(calendar.birthdayOn(birthdays, "spring", 7, 2), "Lewis");
|
||||||
assert.equal(calendar.birthdayOn(birthdays, "spring", 8, 1), null);
|
assert.equal(calendar.birthdayOn(birthdays, "spring", 8, 1), null);
|
||||||
|
|
||||||
console.log("calendar tests passed");
|
console.log("calendar tests passed");
|
||||||
|
|
||||||
|
// Parsnip (4 days) planted on Spring 1 is ready on Spring 5.
|
||||||
|
assert.deepEqual(calendar.harvestDate({ year: 1, season: "spring", day: 1 }, 4), { year: 1, season: "spring", day: 5 });
|
||||||
|
// Corn planted late in Summer finishes in Fall; Winter rolls over into the next year.
|
||||||
|
assert.deepEqual(calendar.harvestDate({ year: 1, season: "summer", day: 20 }, 14), { year: 1, season: "fall", day: 6 });
|
||||||
|
assert.deepEqual(calendar.harvestDate({ year: 1, season: "winter", day: 28 }, 1), { year: 2, season: "spring", day: 1 });
|
||||||
|
assert.equal(calendar.daysBetween({ year: 1, season: "spring", day: 3 }, { year: 1, season: "spring", day: 5 }), 2);
|
||||||
|
assert.equal(calendar.daysBetween({ year: 1, season: "fall", day: 2 }, { year: 1, season: "summer", day: 28 }), -2);
|
||||||
|
|
||||||
|
console.log("harvest date tests passed");
|
||||||
|
|
|
||||||
|
|
@ -28,3 +28,18 @@ assert.equal(confirmed.checks["crop-parsnip"], true);
|
||||||
assert.equal(confirmed.revision, 7);
|
assert.equal(confirmed.revision, 7);
|
||||||
|
|
||||||
console.log("sync race tests passed");
|
console.log("sync race tests passed");
|
||||||
|
|
||||||
|
// Plantings follow the same rules: an in-flight local planting (or removal) wins.
|
||||||
|
const springFive = { year: 1, season: "spring", day: 5 };
|
||||||
|
const plantMerge = sync.mergeSnapshot({}, new Set(), 7, {
|
||||||
|
revision: 8,
|
||||||
|
checks: {},
|
||||||
|
plantings: { "crop-parsnip": { year: 1, season: "spring", day: 1 }, "crop-potato": springFive }
|
||||||
|
}, { "crop-parsnip": springFive }, new Set(["crop-parsnip", "crop-potato"]));
|
||||||
|
assert.deepEqual(plantMerge.plantings, { "crop-parsnip": springFive });
|
||||||
|
|
||||||
|
const unplanted = sync.confirmPlanting(plantMerge.plantings, 8, { itemId: "crop-parsnip", planted: null, revision: 9 });
|
||||||
|
assert.deepEqual(unplanted.plantings, {});
|
||||||
|
assert.equal(unplanted.revision, 9);
|
||||||
|
|
||||||
|
console.log("planting sync tests passed");
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue