Fix concurrent checklist sync and consistent actions

This commit is contained in:
Codex 2026-10-04 20:19:03 +01:00
parent a33f7a297d
commit 8d5a94d7fa
10 changed files with 124 additions and 20 deletions

View file

@ -1,5 +1,6 @@
(() => {
const data = window.VALLEY_DATA;
const sync = window.ValleySync;
const weekdays = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
const seasonNames = { spring: "Spring", summer: "Summer", fall: "Fall", winter: "Winter" };
const state = {
@ -89,22 +90,31 @@
return data.crops.filter(crop => crop.season === state.season && crop.last === state.day && (!crop.year || crop.year === state.year));
}
function card(kind, label, title, copy) {
return `<article class="today-card ${kind}"><div class="card-label"><span></span>${escapeHtml(label)}</div><h3>${escapeHtml(title)}</h3><p>${escapeHtml(copy)}</p></article>`;
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 card(kind, label, title, copy, itemId = null) {
const checked = itemId ? Boolean(state.checks[itemId]) : false;
const content = `${itemId ? `<input type="checkbox" data-check-id="${itemId}" ${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>`;
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."));
if (event) cards.push(card("event", "Event", event[0], event[1]));
reminders().forEach(([title, copy]) => cards.push(card("reminder", "Reminder", title, copy)));
cropDeadlineCards().forEach(crop => cards.push(card("deadline", "Plant by today", crop.name, crop.purpose)));
if (birthday) cards.push(card("birthday", "Birthday", birthday, "A birthday gift gives 8× the usual friendship effect.", dailyId("birthday", 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 => cards.push(card("deadline", "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`;
$("#today-summary").textContent = `${cards.length} ${noun} on the calendar · tick cards when done`;
}
function renderCrops() {
@ -187,9 +197,11 @@
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();
if (snapshot.revision !== state.revision) {
state.checks = snapshot.checks || {};
state.revision = snapshot.revision;
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();
}
@ -204,6 +216,7 @@
const previous = Boolean(state.checks[itemId]);
state.checks[itemId] = checked;
state.saving.add(itemId);
renderToday();
renderCrops();
renderBundles();
setSyncStatus("loading", "Saving…");
@ -215,15 +228,19 @@
});
if (!response.ok) throw new Error(`Save failed (${response.status})`);
const result = await response.json();
state.revision = Math.max(state.revision, result.revision);
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();
}

View file

@ -6,7 +6,7 @@
<meta name="theme-color" content="#132720">
<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="stylesheet" href="/styles.css">
<link rel="stylesheet" href="/styles.css?v=1.1.0">
<title>Valley Yearbook</title>
</head>
<body>
@ -59,7 +59,7 @@
<section id="today" class="content-section today-section" aria-labelledby="today-heading">
<div class="section-heading">
<div><p class="eyebrow">At a glance</p><h2 id="today-heading">Today in the valley</h2></div>
<p id="today-summary"></p>
<p id="today-summary">Tick a card when you’ve handled it.</p>
</div>
<div id="today-cards" class="today-grid"></div>
</section>
@ -94,7 +94,8 @@
</footer>
</div>
<div id="toast" class="toast" role="alert" aria-live="assertive"></div>
<script src="/data.js"></script>
<script src="/app.js"></script>
<script src="/data.js?v=1.1.0"></script>
<script src="/sync.js?v=1.1.0"></script>
<script src="/app.js?v=1.1.0"></script>
</body>
</html>

View file

@ -91,6 +91,12 @@ h1 { margin-bottom: 3px; font: 700 clamp(2.8rem, 8vw, 5.8rem)/.92 Georgia, serif
.section-heading > p { color: var(--muted); max-width: 31rem; margin: 0; text-align: right; font-size: .92rem; }
.today-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.today-card { min-height: 180px; padding: 22px; background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); position: relative; overflow: hidden; }
label.today-card { display: block; cursor: pointer; transition: border-color .15s ease, opacity .15s ease; }
label.today-card:hover { border-color: color-mix(in srgb, var(--season) 55%, transparent); }
.today-card > input { position: absolute; opacity: 0; pointer-events: none; }
.today-card > .checkmark { position: absolute; z-index: 2; top: 18px; right: 18px; background: var(--panel-2); }
.today-card.complete { opacity: .58; }
.today-card.complete h3 { text-decoration: line-through; }
.today-card::after { content: ""; position: absolute; width: 100px; height: 100px; right: -42px; bottom: -42px; border: 22px solid var(--season-soft); border-radius: 50%; }
.today-card h3 { font: 700 1.35rem/1.1 Georgia, serif; margin: 25px 0 9px; }
.today-card p { color: var(--muted); margin: 0; font-size: .91rem; max-width: 26rem; }

26
static/sync.js Normal file
View file

@ -0,0 +1,26 @@
(function exposeSync(root, factory) {
const api = factory();
if (typeof module === "object" && module.exports) module.exports = api;
if (root) root.ValleySync = api;
})(typeof window === "undefined" ? globalThis : window, () => ({
mergeSnapshot(localChecks, savingIds, currentRevision, snapshot) {
if (!snapshot || !Number.isInteger(snapshot.revision) || snapshot.revision <= currentRevision) {
return { applied: false, revision: currentRevision, checks: localChecks };
}
const checks = { ...(snapshot.checks || {}) };
for (const itemId of savingIds) {
if (Object.prototype.hasOwnProperty.call(localChecks, itemId)) {
checks[itemId] = Boolean(localChecks[itemId]);
}
}
return { applied: true, revision: snapshot.revision, checks };
},
confirmWrite(localChecks, currentRevision, result) {
return {
checks: { ...localChecks, [result.itemId]: Boolean(result.checked) },
revision: Math.max(currentRevision, result.revision)
};
}
}));