diff --git a/README.md b/README.md index 5b6c362..22e54d9 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app.py b/app.py index eac1bfb..5f9095d 100644 --- a/app.py +++ b/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) diff --git a/flake.nix b/flake.nix index b304734..6ace621 100644 --- a/flake.nix +++ b/flake.nix @@ -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 ''; }); diff --git a/package.nix b/package.nix index 9f9fa44..23f453a 100644 --- a/package.nix +++ b/package.nix @@ -2,7 +2,7 @@ stdenvNoCC.mkDerivation { pname = "stardew-tracker"; - version = "1.0.0"; + version = "1.1.0"; src = ./.; nativeBuildInputs = [ makeWrapper ]; diff --git a/static/app.js b/static/app.js index 7c64b42..6a3ac8c 100644 --- a/static/app.js +++ b/static/app.js @@ -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 `
${escapeHtml(label)}

${escapeHtml(title)}

${escapeHtml(copy)}

`; + 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 ? `` : ""}
${escapeHtml(label)}

${escapeHtml(title)}

${escapeHtml(copy)}

`; + return itemId + ? `` + : `
${content}
`; } 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(); } diff --git a/static/index.html b/static/index.html index 99e6e44..f39ca0f 100644 --- a/static/index.html +++ b/static/index.html @@ -6,7 +6,7 @@ - + Valley Yearbook @@ -59,7 +59,7 @@

At a glance

Today in the valley

-

+

Tick a card when you’ve handled it.

@@ -94,7 +94,8 @@ - - + + + diff --git a/static/styles.css b/static/styles.css index 38d19fa..bd0562b 100644 --- a/static/styles.css +++ b/static/styles.css @@ -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; } diff --git a/static/sync.js b/static/sync.js new file mode 100644 index 0000000..eb1e268 --- /dev/null +++ b/static/sync.js @@ -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) + }; + } +})); diff --git a/tests/test_app.py b/tests/test_app.py index 145c451..7d098a0 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -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() diff --git a/tests/test_sync.js b/tests/test_sync.js new file mode 100644 index 0000000..ac7f238 --- /dev/null +++ b/tests/test_sync.js @@ -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");