Record planting day and show harvest dates

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-06 10:07:21 +01:00
parent 07057e6c3d
commit 83a9cb6dcc
11 changed files with 282 additions and 44 deletions

View file

@ -11,18 +11,30 @@
day: Number(localStorage.getItem("valley-day")) || 1,
filter: "all",
checks: {},
plantings: {},
revision: -1,
saving: new Set()
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;
// 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)]);
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);
@ -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 (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 = 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)));
});
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 key = itemKey(crop);
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 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>`;
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(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="grow-time">${crop.grow} days<small>to grow</small></span>
<span class="plant-date ${today ? "today" : ""}">${date}</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>`;
@ -210,7 +241,12 @@
function statusBadges(status, entry) {
if (status.checked) return "";
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>`);
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>`);
@ -231,7 +267,7 @@
<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(" ");
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>`;
@ -277,9 +313,10 @@
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);
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();
@ -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() {
const context = document.modelContext;
if (!context?.registerTool) return;
@ -336,7 +409,7 @@
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]) })
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",
@ -366,7 +439,7 @@
renderBundles();
}
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");
if (navLink) {
$$(".section-nav a").forEach(link => link.classList.toggle("active", link === navLink));