Fix concurrent checklist sync and consistent actions
This commit is contained in:
parent
a33f7a297d
commit
8d5a94d7fa
10 changed files with 124 additions and 20 deletions
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
26
static/sync.js
Normal 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)
|
||||
};
|
||||
}
|
||||
}));
|
||||
Loading…
Add table
Add a link
Reference in a new issue