stardew-tracker/static/app.js
rope 07057e6c3d Mark crops as planted (on the way)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 22:56:06 +01:00

391 lines
20 KiB
JavaScript
Raw 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: {},
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;
// Planting is tracked as its own shared flag so "on the way" never counts
// toward bundle completion.
const plantedId = key => `planted-${key}`;
const isPlanted = key => !state.checks[key] && Boolean(state.checks[plantedId(key)]);
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))));
cropDeadlineCards().forEach(crop => {
const planted = isPlanted(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 = isPlanted(key);
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="${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 date = planted
? `On the way<small>planted</small>`
: `${overdue ? "Passed" : `${seasonNames[crop.season]} ${crop.last}`}<small>${overdue ? "for this season" : "plant by"}</small>`;
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(crop.purpose)}</small></span>
<span class="plant-slot">${plantButton}</span>
<span class="grow-time">${crop.grow} days<small>to grow</small></span>
<span class="plant-date ${today ? "today" : ""}">${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 (isPlanted(entry.id)) badges.push(`<span class="status-badge planted">🌱 Planted</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" : "", isPlanted(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);
if (merged.applied) {
state.checks = merged.checks;
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();
}
}
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("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) updateCheck(plantButton.dataset.plantId, !state.checks[plantButton.dataset.plantId]);
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();
})();