Highlight seasonal Community Center priorities

This commit is contained in:
Codex 2026-10-05 19:10:19 +01:00
parent 8d5a94d7fa
commit d2c7f004ea
8 changed files with 199 additions and 16 deletions

View file

@ -9,6 +9,8 @@ A small, shared tracker for the first two years of Stardew Valley. It combines:
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. 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.
Community Center filters distinguish all unfinished items, items available in the selected season, season-exclusive items, and fish that require a rain or sun window. Choose-N bundles stop treating unused alternatives as required once their target is met.
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. 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 ## Run locally

View file

@ -31,7 +31,9 @@
node --check static/app.js node --check static/app.js
node --check static/data.js node --check static/data.js
node --check static/sync.js node --check static/sync.js
node --check static/availability.js
node tests/test_sync.js node tests/test_sync.js
node tests/test_availability.js
touch $out touch $out
''; '';
}); });

View file

@ -2,7 +2,7 @@
stdenvNoCC.mkDerivation { stdenvNoCC.mkDerivation {
pname = "stardew-tracker"; pname = "stardew-tracker";
version = "1.1.0"; version = "1.2.0";
src = ./.; src = ./.;
nativeBuildInputs = [ makeWrapper ]; nativeBuildInputs = [ makeWrapper ];

View file

@ -1,6 +1,7 @@
(() => { (() => {
const data = window.VALLEY_DATA; const data = window.VALLEY_DATA;
const sync = window.ValleySync; const sync = window.ValleySync;
const availability = window.ValleyAvailability;
const weekdays = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]; const weekdays = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
const seasonNames = { spring: "Spring", summer: "Summer", fall: "Fall", winter: "Winter" }; const seasonNames = { spring: "Spring", summer: "Summer", fall: "Fall", winter: "Winter" };
const state = { const state = {
@ -34,7 +35,7 @@
renderDate(); renderDate();
renderToday(); renderToday();
renderCrops(); renderCrops();
if (state.filter === "season") renderBundles(); renderBundles();
} }
function moveDay(delta) { function moveDay(delta) {
@ -149,19 +150,60 @@
}, totals), { done: 0, required: 0 }); }, totals), { done: 0, required: 0 });
} }
function entryStatus(entry, group) {
const progress = groupProgress(group);
return availability.classify(entry, {
checked: Boolean(state.checks[entry.id]),
chooseGroupComplete: Boolean(group.choose && progress.complete),
season: state.season
});
}
function allEntryStatuses() {
return data.bundles.flatMap(room => room.groups.flatMap(group =>
group.items.map(entry => ({ entry, group, status: entryStatus(entry, group) }))
));
}
function renderPrioritySummary() {
const entries = allEntryStatuses();
const count = filter => entries.filter(({ status }) => availability.matchesFilter(status, filter)).length;
$("#needed-now-count").textContent = count("now");
$("#exclusive-count").textContent = count("exclusive");
$("#weather-count").textContent = count("weather");
$("#needed-now-detail").textContent = `Available in ${seasonNames[state.season]}`;
$("#exclusive-detail").textContent = `Only in ${seasonNames[state.season]}`;
}
function seasonList(seasons) {
return seasons.map(season => seasonNames[season]).join(" + ");
}
function statusBadges(status) {
if (status.checked) return "";
const badges = [];
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>`);
else if (status.seasonal) badges.push(`<span class="status-badge later">${seasonList(status.seasons)}</span>`);
if (status.weather) badges.push(`<span class="status-badge weather ${status.weather.toLowerCase()}">${status.weather === "Rain" ? "Rain needed" : "Sunny only"}</span>`);
return badges.length ? `<span class="item-badges">${badges.join("")}</span>` : "";
}
function renderBundles() { function renderBundles() {
const html = data.bundles.map(room => { const html = data.bundles.map(room => {
const groups = room.groups.map(group => { const groups = room.groups.map(group => {
const progress = groupProgress(group); const progress = groupProgress(group);
let items = group.items; let items = group.items;
if (state.filter === "season") items = items.filter(entry => !entry.seasons.length || entry.seasons.includes(state.season)); if (state.filter !== "all") items = items.filter(entry => availability.matchesFilter(entryStatus(entry, group), state.filter));
if (state.filter === "open") items = items.filter(entry => !state.checks[entry.id]);
if (!items.length) return ""; if (!items.length) return "";
return `<article class="bundle-card ${progress.complete ? "complete" : ""}"> return `<article class="bundle-card ${progress.complete ? "complete" : ""}">
<header><div><h4>${escapeHtml(group.name)}</h4>${group.choose ? `<small>Choose ${group.required}</small>` : ""}</div><span>${Math.min(progress.done, progress.required)}/${progress.required}</span></header> <header><div><h4>${escapeHtml(group.name)}</h4>${group.choose ? `<small>Choose ${group.required}</small>` : ""}</div><span>${Math.min(progress.done, progress.required)}/${progress.required}</span></header>
<div class="bundle-items">${items.map(entry => { <div class="bundle-items">${items.map(entry => {
const checked = Boolean(state.checks[entry.id]); const checked = Boolean(state.checks[entry.id]);
return `<label class="bundle-item ${checked ? "complete" : ""}"><input type="checkbox" data-check-id="${entry.id}" ${checked ? "checked" : ""} ${state.saving.has(entry.id) ? "disabled" : ""}><span class="checkmark" aria-hidden="true"></span><span><strong>${escapeHtml(entry.label)}</strong><small>${escapeHtml(entry.meta)}</small></span></label>`; 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" : ""].filter(Boolean).join(" ");
return `<label class="bundle-item ${classes}"><input type="checkbox" data-check-id="${entry.id}" ${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)}</span></label>`;
}).join("")}</div> }).join("")}</div>
</article>`; </article>`;
}).filter(Boolean).join(""); }).filter(Boolean).join("");
@ -170,7 +212,16 @@
const roomDone = roomGroups.filter(progress => progress.complete).length; const roomDone = roomGroups.filter(progress => progress.complete).length;
return `<section class="room"><div class="room-title"><span>${room.icon}</span><div><h3>${escapeHtml(room.room)}</h3><p>${roomDone} of ${room.groups.length} bundles ready</p></div></div><div class="room-grid">${groups}</div></section>`; return `<section class="room"><div class="room-title"><span>${room.icon}</span><div><h3>${escapeHtml(room.room)}</h3><p>${roomDone} of ${room.groups.length} bundles ready</p></div></div><div class="room-grid">${groups}</div></section>`;
}).filter(Boolean).join(""); }).filter(Boolean).join("");
$("#bundle-list").innerHTML = html || `<div class="empty-state"><span>✓</span><h3>Everything in this view is done</h3><p>Switch filters to review the whole Community Center.</p></div>`; const emptyMessages = {
now: ["Nothing needed right now", `No unfinished bundle items are available from normal sources in ${seasonNames[state.season]}.`],
exclusive: ["No season-only items left", `You have covered every unfinished item exclusive to ${seasonNames[state.season]}.`],
weather: ["No weather windows left", `No unfinished fish in ${seasonNames[state.season]} require rain or sun.`],
open: ["Community Center covered", "No required bundle items remain."],
all: ["Everything in this view is done", "Switch filters to review the whole Community Center."]
};
const empty = emptyMessages[state.filter] || emptyMessages.all;
$("#bundle-list").innerHTML = html || `<div class="empty-state"><span>✓</span><h3>${empty[0]}</h3><p>${empty[1]}</p></div>`;
renderPrioritySummary();
const totals = bundleTotals(); const totals = bundleTotals();
const percent = totals.required ? Math.round(totals.done / totals.required * 100) : 0; const percent = totals.required ? Math.round(totals.done / totals.required * 100) : 0;
$("#progress-label").textContent = `${totals.done} of ${totals.required} essentials`; $("#progress-label").textContent = `${totals.done} of ${totals.required} essentials`;
@ -282,7 +333,7 @@
const filterButton = event.target.closest("[data-filter]"); const filterButton = event.target.closest("[data-filter]");
if (filterButton) { if (filterButton) {
state.filter = filterButton.dataset.filter; state.filter = filterButton.dataset.filter;
$$("[data-filter]").forEach(button => button.setAttribute("aria-pressed", String(button === filterButton))); $$("[data-filter]").forEach(button => button.setAttribute("aria-pressed", String(button.dataset.filter === state.filter)));
renderBundles(); renderBundles();
} }
const navLink = event.target.closest(".section-nav a"); const navLink = event.target.closest(".section-nav a");

41
static/availability.js Normal file
View file

@ -0,0 +1,41 @@
(function exposeAvailability(root, factory) {
const api = factory();
if (typeof module === "object" && module.exports) module.exports = api;
if (root) root.ValleyAvailability = api;
})(typeof window === "undefined" ? globalThis : window, () => {
const weatherFor = entry => {
if (!String(entry.id || "").startsWith("fish-")) return null;
const detail = String(entry.meta || "").toLowerCase();
if (detail.includes("rain")) return "Rain";
if (detail.includes("sunny")) return "Sun";
return null;
};
const classify = (entry, { checked, chooseGroupComplete, season }) => {
const seasons = Array.isArray(entry.seasons) ? entry.seasons : [];
const seasonal = seasons.length > 0;
const inSeason = seasons.includes(season);
const availableNow = !seasonal || inSeason;
const needed = !checked && !chooseGroupComplete;
return {
checked,
needed,
seasonal,
inSeason,
availableNow,
seasonExclusive: needed && inSeason && seasons.length === 1,
weather: weatherFor(entry),
seasons
};
};
const matchesFilter = (status, filter) => {
if (filter === "open") return status.needed;
if (filter === "now") return status.needed && status.availableNow;
if (filter === "exclusive") return status.seasonExclusive;
if (filter === "weather") return status.needed && status.availableNow && Boolean(status.weather);
return true;
};
return { classify, matchesFilter, weatherFor };
});

View file

@ -6,7 +6,7 @@
<meta name="theme-color" content="#132720"> <meta name="theme-color" content="#132720">
<meta name="description" content="A shared two-year Stardew Valley Community Center planner."> <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="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="/styles.css?v=1.1.0"> <link rel="stylesheet" href="/styles.css?v=1.2.0">
<title>Valley Yearbook</title> <title>Valley Yearbook</title>
</head> </head>
<body> <body>
@ -80,11 +80,31 @@
<div class="progress-track" aria-hidden="true"><i id="progress-bar"></i></div> <div class="progress-track" aria-hidden="true"><i id="progress-bar"></i></div>
</div> </div>
</div> </div>
<div class="priority-summary" id="priority-summary" aria-label="Season priorities">
<button type="button" data-filter="now" aria-pressed="false">
<strong id="needed-now-count">0</strong>
<span>Needed now</span>
<small id="needed-now-detail">Available this season</small>
</button>
<button type="button" data-filter="exclusive" aria-pressed="false">
<strong id="exclusive-count">0</strong>
<span>Season-only</span>
<small id="exclusive-detail">Miss it, wait a year</small>
</button>
<button type="button" data-filter="weather" aria-pressed="false">
<strong id="weather-count">0</strong>
<span>Weather window</span>
<small>Rain or sun required</small>
</button>
</div>
<div class="bundle-toolbar" role="group" aria-label="Bundle filter"> <div class="bundle-toolbar" role="group" aria-label="Bundle filter">
<button type="button" data-filter="all" aria-pressed="true">All rooms</button> <button type="button" data-filter="all" aria-pressed="true">All rooms</button>
<button type="button" data-filter="season" aria-pressed="false">Available this season</button> <button type="button" data-filter="now" aria-pressed="false">Needed now</button>
<button type="button" data-filter="open" aria-pressed="false">Still needed</button> <button type="button" data-filter="exclusive" aria-pressed="false">Season-only</button>
<button type="button" data-filter="weather" aria-pressed="false">Weather-specific</button>
<button type="button" data-filter="open" aria-pressed="false">All unfinished</button>
</div> </div>
<p class="availability-note"><span aria-hidden="true">◆</span> Availability follows normal sources for the selected season; the Traveling Cart can occasionally provide exceptions.</p>
<div id="bundle-list" class="bundle-list"></div> <div id="bundle-list" class="bundle-list"></div>
</section> </section>
</main> </main>
@ -94,8 +114,9 @@
</footer> </footer>
</div> </div>
<div id="toast" class="toast" role="alert" aria-live="assertive"></div> <div id="toast" class="toast" role="alert" aria-live="assertive"></div>
<script src="/data.js?v=1.1.0"></script> <script src="/data.js?v=1.2.0"></script>
<script src="/sync.js?v=1.1.0"></script> <script src="/sync.js?v=1.2.0"></script>
<script src="/app.js?v=1.1.0"></script> <script src="/availability.js?v=1.2.0"></script>
<script src="/app.js?v=1.2.0"></script>
</body> </body>
</html> </html>

View file

@ -126,7 +126,17 @@ label.today-card:hover { border-color: color-mix(in srgb, var(--season) 55%, tra
.progress-cluster { width: min(290px, 42%); color: var(--muted); text-align: right; font-size: .84rem; } .progress-cluster { width: min(290px, 42%); color: var(--muted); text-align: right; font-size: .84rem; }
.progress-track { height: 8px; background: var(--panel-2); border-radius: 99px; margin-top: 9px; overflow: hidden; } .progress-track { height: 8px; background: var(--panel-2); border-radius: 99px; margin-top: 9px; overflow: hidden; }
.progress-track i { display: block; height: 100%; width: 0; background: linear-gradient(90deg, var(--season), var(--accent)); border-radius: inherit; transition: width .3s ease; } .progress-track i { display: block; height: 100%; width: 0; background: linear-gradient(90deg, var(--season), var(--accent)); border-radius: inherit; transition: width .3s ease; }
.bundle-toolbar { display: inline-flex; gap: 3px; padding: 4px; background: var(--panel); border: 1px solid var(--line); border-radius: 999px; margin-bottom: 30px; } .priority-summary { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-bottom: 16px; }
.priority-summary button { display: grid; grid-template-columns: auto 1fr; column-gap: 12px; align-items: center; text-align: left; border: 1px solid var(--line); border-radius: 15px; background: var(--panel); padding: 15px 17px; cursor: pointer; color: var(--ink); }
.priority-summary button:hover, .priority-summary button[aria-pressed="true"] { border-color: var(--season); background: var(--season-soft); }
.priority-summary strong { grid-row: 1 / 3; font: 700 2rem/1 Georgia, serif; color: var(--season); }
.priority-summary span { align-self: end; font-size: .88rem; font-weight: 800; }
.priority-summary small { align-self: start; color: var(--muted); font-size: .7rem; }
.priority-summary button:nth-child(2) strong { color: var(--accent); }
.priority-summary button:nth-child(3) strong { color: #86bcd8; }
.bundle-toolbar { display: inline-flex; gap: 3px; padding: 4px; background: var(--panel); border: 1px solid var(--line); border-radius: 999px; margin-bottom: 12px; }
.availability-note { display: flex; gap: 8px; align-items: baseline; color: var(--muted); font-size: .76rem; margin: 0 0 30px; }
.availability-note span { color: var(--accent); }
.room { display: grid; grid-template-columns: 180px minmax(0, 1fr); gap: 24px; margin-bottom: 46px; } .room { display: grid; grid-template-columns: 180px minmax(0, 1fr); gap: 24px; margin-bottom: 46px; }
.room-title { display: flex; gap: 13px; align-items: start; position: sticky; top: 74px; height: min-content; } .room-title { display: flex; gap: 13px; align-items: start; position: sticky; top: 74px; height: min-content; }
.room-title > span { width: 40px; height: 40px; display: grid; place-items: center; background: var(--season-soft); color: var(--season); border-radius: 12px; font-size: 1.1rem; } .room-title > span { width: 40px; height: 40px; display: grid; place-items: center; background: var(--season-soft); color: var(--season); border-radius: 12px; font-size: 1.1rem; }
@ -140,11 +150,22 @@ label.today-card:hover { border-color: color-mix(in srgb, var(--season) 55%, tra
.bundle-card header > span { color: var(--muted); font-size: .8rem; font-weight: 800; } .bundle-card header > span { color: var(--muted); font-size: .8rem; font-weight: 800; }
.bundle-card.complete > header { background: var(--season-soft); } .bundle-card.complete > header { background: var(--season-soft); }
.bundle-card.complete header > span { color: var(--season); } .bundle-card.complete header > span { color: var(--season); }
.bundle-item { min-height: 59px; display: grid; grid-template-columns: 22px 1fr; gap: 11px; align-items: center; padding: 10px 16px; border-bottom: 1px solid var(--line); cursor: pointer; } .bundle-item { min-height: 59px; display: grid; grid-template-columns: 22px 1fr; gap: 11px; align-items: center; padding: 10px 16px; border-bottom: 1px solid var(--line); border-left: 3px solid transparent; cursor: pointer; }
.bundle-item:last-child { border: 0; } .bundle-item:last-child { border-bottom: 0; }
.bundle-item:hover { background: rgba(255,255,255,.025); } .bundle-item:hover { background: rgba(255,255,255,.025); }
.bundle-item.complete > span:last-child { opacity: .5; } .bundle-item.complete > span:last-child { opacity: .5; }
.bundle-item.complete strong { text-decoration: line-through; } .bundle-item.complete strong { text-decoration: line-through; }
.bundle-item.needed.available-now { border-left-color: var(--season); background: color-mix(in srgb, var(--season) 5%, transparent); }
.bundle-item.needed.season-exclusive { border-left-color: var(--accent); background: rgba(241,201,109,.055); }
.bundle-item.optional:not(.complete) { opacity: .55; }
.item-badges { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 7px; }
.status-badge { display: inline-flex; width: max-content; padding: 3px 7px; border-radius: 999px; text-transform: uppercase; letter-spacing: .06em; font-size: .62rem; line-height: 1.2; font-weight: 850; background: rgba(255,255,255,.06); color: var(--muted); }
.status-badge.in-season { color: #a9dfa4; background: rgba(121,181,107,.16); }
.status-badge.exclusive { color: #f6d98d; background: rgba(241,201,109,.15); }
.status-badge.weather.rain { color: #b9dff2; background: rgba(134,188,216,.18); }
.status-badge.weather.sun { color: #f5d278; background: rgba(240,184,79,.15); }
.status-badge.later { color: #b7c2bc; }
.status-badge.optional { color: #9eaaa3; }
.empty-state { border: 1px dashed rgba(241,201,109,.28); border-radius: var(--radius); text-align: center; padding: 50px 20px; background: rgba(19,39,32,.55); } .empty-state { border: 1px dashed rgba(241,201,109,.28); border-radius: var(--radius); text-align: center; padding: 50px 20px; background: rgba(19,39,32,.55); }
.empty-state > span { color: var(--season); font-size: 2rem; } .empty-state > span { color: var(--season); font-size: 2rem; }
.empty-state h3 { margin: 10px 0 5px; font: 700 1.3rem Georgia, serif; } .empty-state h3 { margin: 10px 0 5px; font: 700 1.3rem Georgia, serif; }
@ -158,6 +179,7 @@ footer a { text-underline-offset: 3px; }
@media (max-width: 840px) { @media (max-width: 840px) {
.today-grid { grid-template-columns: repeat(2, 1fr); } .today-grid { grid-template-columns: repeat(2, 1fr); }
.priority-summary { grid-template-columns: 1fr; }
.room { grid-template-columns: 1fr; } .room { grid-template-columns: 1fr; }
.room-title { position: static; } .room-title { position: static; }
} }

View file

@ -0,0 +1,44 @@
const assert = require("node:assert/strict");
const availability = require("../static/availability.js");
const walleye = {
id: "fish-walleye",
meta: "River/Lake · 12pm–2am · rain",
seasons: ["fall"]
};
const fallWindow = availability.classify(walleye, {
checked: false,
chooseGroupComplete: false,
season: "fall"
});
assert.equal(fallWindow.needed, true);
assert.equal(fallWindow.availableNow, true);
assert.equal(fallWindow.seasonExclusive, true);
assert.equal(fallWindow.weather, "Rain");
assert.equal(availability.matchesFilter(fallWindow, "now"), true);
assert.equal(availability.matchesFilter(fallWindow, "exclusive"), true);
assert.equal(availability.matchesFilter(fallWindow, "weather"), true);
const springWalleye = availability.classify(walleye, {
checked: false,
chooseGroupComplete: false,
season: "spring"
});
assert.equal(springWalleye.availableNow, false);
assert.equal(availability.matchesFilter(springWalleye, "now"), false);
assert.equal(availability.matchesFilter(springWalleye, "weather"), false);
const optionalChoice = availability.classify({
id: "crab-clam",
meta: "Ocean crab pot",
seasons: []
}, {
checked: false,
chooseGroupComplete: true,
season: "spring"
});
assert.equal(optionalChoice.needed, false);
assert.equal(availability.matchesFilter(optionalChoice, "open"), false);
assert.equal(availability.matchesFilter(optionalChoice, "now"), false);
console.log("availability tests passed");