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
|
|
@ -7,6 +7,8 @@ A small, shared tracker for the first two years of Stardew Valley. It combines:
|
|||
- birthdays, festivals, seasonal events, and weekly reminders; and
|
||||
- one server-side checklist that stays in sync across phones and computers.
|
||||
|
||||
Every actionable card and checklist row uses the same shared checkbox behavior. Date and season selection remain local to each device, so two players can look at different days without moving each other's calendar.
|
||||
|
||||
The app is deliberately dependency-free at runtime: Python's standard library serves the site and stores progress in SQLite. Nginx/Authelia are expected to provide the public access boundary.
|
||||
|
||||
## Run locally
|
||||
|
|
|
|||
12
app.py
12
app.py
|
|
@ -9,6 +9,7 @@ import logging
|
|||
import mimetypes
|
||||
import re
|
||||
import sqlite3
|
||||
from contextlib import closing
|
||||
from datetime import datetime, timezone
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
|
@ -38,7 +39,7 @@ class Store:
|
|||
return connection
|
||||
|
||||
def initialize(self) -> None:
|
||||
with self.connect() as db:
|
||||
with closing(self.connect()) as db, db:
|
||||
db.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS checks (
|
||||
|
|
@ -56,7 +57,7 @@ class Store:
|
|||
)
|
||||
|
||||
def snapshot(self) -> dict:
|
||||
with self.connect() as db:
|
||||
with closing(self.connect()) as db, db:
|
||||
revision = int(
|
||||
db.execute("SELECT value FROM metadata WHERE key = 'revision'").fetchone()[0]
|
||||
)
|
||||
|
|
@ -75,7 +76,7 @@ class Store:
|
|||
|
||||
def set_check(self, item_id: str, checked: bool, user: str) -> dict:
|
||||
changed_at = utc_now()
|
||||
with self.connect() as db:
|
||||
with closing(self.connect()) as db, db:
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
db.execute(
|
||||
"""
|
||||
|
|
@ -206,7 +207,10 @@ class Handler(BaseHTTPRequestHandler):
|
|||
}
|
||||
self.send_header("Content-Type", f"{content_type}; charset=utf-8" if textual else content_type)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "no-cache" if target.name == "index.html" else "public, max-age=3600")
|
||||
# Source filenames are intentionally stable. Always revalidate them so
|
||||
# two household clients cannot run different interaction code for an
|
||||
# hour after a deployment.
|
||||
self.send_header("Cache-Control", "no-cache" if textual else "public, max-age=3600")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@
|
|||
python3 -m py_compile app.py
|
||||
node --check static/app.js
|
||||
node --check static/data.js
|
||||
node --check static/sync.js
|
||||
node tests/test_sync.js
|
||||
touch $out
|
||||
'';
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
stdenvNoCC.mkDerivation {
|
||||
pname = "stardew-tracker";
|
||||
version = "1.0.0";
|
||||
version = "1.1.0";
|
||||
src = ./.;
|
||||
|
||||
nativeBuildInputs = [ makeWrapper ];
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
};
|
||||
}
|
||||
}));
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import tempfile
|
||||
import unittest
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
|
||||
import app
|
||||
|
|
@ -30,6 +31,21 @@ class StoreTests(unittest.TestCase):
|
|||
self.assertFalse(snapshot["checks"]["crop-parsnip"])
|
||||
self.assertEqual(snapshot["revision"], 2)
|
||||
|
||||
def test_two_clients_keep_distinct_concurrent_changes(self):
|
||||
second_client = app.Store(self.store.path)
|
||||
|
||||
def write(index):
|
||||
store = self.store if index % 2 else second_client
|
||||
store.set_check(f"concurrent-item-{index}", True, f"player-{index % 2}")
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
list(pool.map(write, range(20)))
|
||||
|
||||
snapshot = self.store.snapshot()
|
||||
self.assertEqual(snapshot["revision"], 20)
|
||||
self.assertEqual(len(snapshot["checks"]), 20)
|
||||
self.assertTrue(all(snapshot["checks"].values()))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
30
tests/test_sync.js
Normal file
30
tests/test_sync.js
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
const assert = require("node:assert/strict");
|
||||
const sync = require("../static/sync.js");
|
||||
|
||||
// A second client advances the shared revision while this client is saving.
|
||||
// The in-flight local value must not be replaced by the older server value.
|
||||
const local = { "crop-parsnip": true, "fish-eel": false };
|
||||
const remoteWhileSaving = {
|
||||
revision: 6,
|
||||
checks: { "crop-parsnip": false, "fish-eel": true }
|
||||
};
|
||||
const merged = sync.mergeSnapshot(local, new Set(["crop-parsnip"]), 5, remoteWhileSaving);
|
||||
assert.equal(merged.applied, true);
|
||||
assert.equal(merged.checks["crop-parsnip"], true);
|
||||
assert.equal(merged.checks["fish-eel"], true);
|
||||
|
||||
// A response already superseded by a later write must never roll the client back.
|
||||
const stale = sync.mergeSnapshot(merged.checks, new Set(), 7, remoteWhileSaving);
|
||||
assert.equal(stale.applied, false);
|
||||
assert.equal(stale.revision, 7);
|
||||
|
||||
// The write response is authoritative for its own item even after an interleaved poll.
|
||||
const confirmed = sync.confirmWrite(merged.checks, 6, {
|
||||
itemId: "crop-parsnip",
|
||||
checked: true,
|
||||
revision: 7
|
||||
});
|
||||
assert.equal(confirmed.checks["crop-parsnip"], true);
|
||||
assert.equal(confirmed.revision, 7);
|
||||
|
||||
console.log("sync race tests passed");
|
||||
Loading…
Add table
Add a link
Reference in a new issue