stardew-tracker/static/app.js
rope 83a9cb6dcc Record planting day and show harvest dates
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 10:07:21 +01:00

464 lines
24 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(() => {
const data = window.VALLEY_DATA;
const sync = window.ValleySync;
const availability = window.ValleyAvailability;
const calendar = window.ValleyCalendar;
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: {},
plantings: {},
revision: -1,
saving: new Set(),
savingPlantings: 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;
const cropFor = key => data.crops.find(crop => itemKey(crop) === key);
// Plantings are stored apart from checks so "on the way" never counts toward
// bundle completion. Crops planted before 1.5 have no recorded day.
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() {
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();
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;
const birthday = birthdayOn(day);
const label = `${seasonNames[state.season]} ${day}, ${weekday(day)}${birthday ? `, ${birthday}'s birthday` : ""}`;
const marker = birthday ? `<span class="birthday-marker" title="${escapeHtml(`${birthday}'s birthday`)}" aria-hidden="true">🎂</span>` : "";
return `<button type="button" class="${day === state.day ? "selected" : ""}" data-day="${day}" aria-label="${escapeHtml(label)}" aria-pressed="${day === state.day}"><small>${weekdays[index % 7].slice(0, 2)}</small><span class="day-number">${day}</span>${marker}</button>`;
}).join("");
}
function birthdayOn(day) {
return calendar.birthdayOn(data.birthdays, state.season, day, state.year);
}
function currentBirthday() {
return birthdayOn(state.day);
}
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 dailyId(kind, title) {
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 36);
return `daily-${state.year}-${state.season}-${state.day}-${kind}-${slug}`;
}
function giftGuide(name) {
const profile = data.giftProfiles[name];
if (!profile) return "";
const giftList = entries => entries.map(entry => `<li>${escapeHtml(entry)}</li>`).join("");
return `<div class="gift-guide" aria-label="Gift preferences for ${escapeHtml(name)}">
<div><strong><span aria-hidden="true">♥</span> Loves</strong><ul>${giftList(profile.loves)}</ul></div>
<div><strong><span aria-hidden="true">×</span> Avoid</strong><ul>${giftList(profile.avoid)}</ul></div>
</div>`;
}
function card(kind, label, title, copy, itemId = null, extra = "") {
const checked = itemId ? Boolean(state.checks[itemId]) : false;
const content = `${itemId ? `<input type="checkbox" data-check-id="${itemId}" aria-label="Mark ${escapeHtml(title)} complete" ${checked ? "checked" : ""} ${state.saving.has(itemId) ? "disabled" : ""}><span class="checkmark" aria-hidden="true"></span>` : ""}<div class="card-label"><span></span>${escapeHtml(label)}</div><h3>${escapeHtml(title)}</h3><p>${escapeHtml(copy)}</p>${extra}`;
return itemId
? `<label class="today-card ${kind} ${checked ? "complete" : ""}">${content}</label>`
: `<article class="today-card ${kind}">${content}</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.", dailyId("birthday", birthday), giftGuide(birthday)));
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))));
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 => {
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)));
});
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 · tick cards when done`;
}
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 planted = Boolean(plantingFor(key));
const harvest = harvestInfo(key, crop.grow);
const overdue = 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="${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>`;
let growTime = `${crop.grow} days<small>to grow</small>`;
let date = `${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" : ""}">
<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="crop-name"><strong>${escapeHtml(crop.name)}</strong><small>${escapeHtml(purpose)}</small></span>
<span class="plant-slot">${plantButton}</span>
<span class="grow-time">${growTime}</span>
<span class="plant-date ${dateClass}">${date}</span>
</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>`;
}
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 entryStatus(entry, group) {
const progress = groupProgress(group);
return availability.classify(entry, {
checked: Boolean(state.checks[entry.id]),
chooseGroupComplete: Boolean(group.choose && progress.complete),
season: state.season
});
}
function allEntryStatuses() {
return data.bundles.flatMap(room => room.groups.flatMap(group =>
group.items.map(entry => ({ entry, group, status: entryStatus(entry, group) }))
));
}
function renderPrioritySummary() {
const entries = allEntryStatuses();
const count = filter => entries.filter(({ status }) => availability.matchesFilter(status, filter)).length;
$("#needed-now-count").textContent = count("now");
$("#exclusive-count").textContent = count("exclusive");
$("#weather-count").textContent = count("weather");
$("#needed-now-detail").textContent = `Available in ${seasonNames[state.season]}`;
$("#exclusive-detail").textContent = `Only in ${seasonNames[state.season]}`;
}
function seasonList(seasons) {
return seasons.map(season => seasonNames[season]).join(" + ");
}
function statusBadges(status, entry) {
if (status.checked) return "";
const badges = [];
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>`);
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.seasonal) badges.push(`<span class="status-badge later">${seasonList(status.seasons)}</span>`);
if (status.weather) badges.push(`<span class="status-badge weather ${status.weather.toLowerCase()}">${status.weather === "Rain" ? "Rain needed" : "Sunny only"}</span>`);
return badges.length ? `<span class="item-badges">${badges.join("")}</span>` : "";
}
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 !== "all") items = items.filter(entry => availability.matchesFilter(entryStatus(entry, group), state.filter));
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]);
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" : "", 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>`;
}).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("");
const emptyMessages = {
now: ["Nothing needed right now", `No unfinished bundle items are available from normal sources in ${seasonNames[state.season]}.`],
exclusive: ["No season-only items left", `You have covered every unfinished item exclusive to ${seasonNames[state.season]}.`],
weather: ["No weather windows left", `No unfinished fish in ${seasonNames[state.season]} require rain or sun.`],
open: ["Community Center covered", "No required bundle items remain."],
all: ["Everything in this view is done", "Switch filters to review the whole Community Center."]
};
const empty = emptyMessages[state.filter] || emptyMessages.all;
$("#bundle-list").innerHTML = html || `<div class="empty-state"><span>✓</span><h3>${empty[0]}</h3><p>${empty[1]}</p></div>`;
renderPrioritySummary();
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();
const merged = sync.mergeSnapshot(state.checks, state.saving, state.revision, snapshot, state.plantings, state.savingPlantings);
if (merged.applied) {
state.checks = merged.checks;
state.plantings = merged.plantings;
state.revision = merged.revision;
renderToday();
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);
renderToday();
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();
const confirmed = sync.confirmWrite(state.checks, state.revision, result);
state.checks = confirmed.checks;
state.revision = confirmed.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.");
void loadState({ quiet: true });
} finally {
state.saving.delete(itemId);
renderToday();
renderCrops();
renderBundles();
}
}
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() {
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]), plantings: state.plantings })
})).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("button[data-year]");
if (yearButton) setDate({ year: Number(yearButton.dataset.year) });
const seasonButton = event.target.closest("button[data-season]");
if (seasonButton) setDate({ season: seasonButton.dataset.season });
const dayButton = event.target.closest("button[data-day]");
if (dayButton) setDate({ day: Number(dayButton.dataset.day) });
const filterButton = event.target.closest("button[data-filter]");
if (filterButton) {
state.filter = filterButton.dataset.filter;
$$("[data-filter]").forEach(button => button.setAttribute("aria-pressed", String(button.dataset.filter === state.filter)));
renderBundles();
}
const plantButton = event.target.closest("button[data-plant-id]");
if (plantButton && !plantButton.disabled) updatePlanting(plantButton.dataset.plantId, plantingFor(plantButton.dataset.plantId) ? null : selectedDate());
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();
})();