391 lines
20 KiB
JavaScript
391 lines
20 KiB
JavaScript
(() => {
|
||
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 => ({ "&": "&", "<": "<", ">": ">", "'": "'", '"': """ }[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();
|
||
})();
|