Highlight seasonal Community Center priorities
This commit is contained in:
parent
8d5a94d7fa
commit
d2c7f004ea
8 changed files with 199 additions and 16 deletions
|
|
@ -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.
|
||||
|
||||
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.
|
||||
|
||||
## Run locally
|
||||
|
|
|
|||
|
|
@ -31,7 +31,9 @@
|
|||
node --check static/app.js
|
||||
node --check static/data.js
|
||||
node --check static/sync.js
|
||||
node --check static/availability.js
|
||||
node tests/test_sync.js
|
||||
node tests/test_availability.js
|
||||
touch $out
|
||||
'';
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
stdenvNoCC.mkDerivation {
|
||||
pname = "stardew-tracker";
|
||||
version = "1.1.0";
|
||||
version = "1.2.0";
|
||||
src = ./.;
|
||||
|
||||
nativeBuildInputs = [ makeWrapper ];
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
(() => {
|
||||
const data = window.VALLEY_DATA;
|
||||
const sync = window.ValleySync;
|
||||
const availability = window.ValleyAvailability;
|
||||
const weekdays = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
|
||||
const seasonNames = { spring: "Spring", summer: "Summer", fall: "Fall", winter: "Winter" };
|
||||
const state = {
|
||||
|
|
@ -34,7 +35,7 @@
|
|||
renderDate();
|
||||
renderToday();
|
||||
renderCrops();
|
||||
if (state.filter === "season") renderBundles();
|
||||
renderBundles();
|
||||
}
|
||||
|
||||
function moveDay(delta) {
|
||||
|
|
@ -149,19 +150,60 @@
|
|||
}, 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() {
|
||||
const html = data.bundles.map(room => {
|
||||
const groups = room.groups.map(group => {
|
||||
const progress = groupProgress(group);
|
||||
let items = group.items;
|
||||
if (state.filter === "season") items = items.filter(entry => !entry.seasons.length || entry.seasons.includes(state.season));
|
||||
if (state.filter === "open") items = items.filter(entry => !state.checks[entry.id]);
|
||||
if (state.filter !== "all") items = items.filter(entry => availability.matchesFilter(entryStatus(entry, group), state.filter));
|
||||
if (!items.length) return "";
|
||||
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>
|
||||
<div class="bundle-items">${items.map(entry => {
|
||||
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>
|
||||
</article>`;
|
||||
}).filter(Boolean).join("");
|
||||
|
|
@ -170,7 +212,16 @@
|
|||
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>`;
|
||||
}).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 percent = totals.required ? Math.round(totals.done / totals.required * 100) : 0;
|
||||
$("#progress-label").textContent = `${totals.done} of ${totals.required} essentials`;
|
||||
|
|
@ -282,7 +333,7 @@
|
|||
const filterButton = event.target.closest("[data-filter]");
|
||||
if (filterButton) {
|
||||
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();
|
||||
}
|
||||
const navLink = event.target.closest(".section-nav a");
|
||||
|
|
|
|||
41
static/availability.js
Normal file
41
static/availability.js
Normal 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 };
|
||||
});
|
||||
|
|
@ -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?v=1.1.0">
|
||||
<link rel="stylesheet" href="/styles.css?v=1.2.0">
|
||||
<title>Valley Yearbook</title>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -80,11 +80,31 @@
|
|||
<div class="progress-track" aria-hidden="true"><i id="progress-bar"></i></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">
|
||||
<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="open" aria-pressed="false">Still needed</button>
|
||||
<button type="button" data-filter="now" aria-pressed="false">Needed now</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>
|
||||
<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>
|
||||
</section>
|
||||
</main>
|
||||
|
|
@ -94,8 +114,9 @@
|
|||
</footer>
|
||||
</div>
|
||||
<div id="toast" class="toast" role="alert" aria-live="assertive"></div>
|
||||
<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>
|
||||
<script src="/data.js?v=1.2.0"></script>
|
||||
<script src="/sync.js?v=1.2.0"></script>
|
||||
<script src="/availability.js?v=1.2.0"></script>
|
||||
<script src="/app.js?v=1.2.0"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -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-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; }
|
||||
.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-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; }
|
||||
|
|
@ -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.complete > header { background: var(--season-soft); }
|
||||
.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:last-child { border: 0; }
|
||||
.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-bottom: 0; }
|
||||
.bundle-item:hover { background: rgba(255,255,255,.025); }
|
||||
.bundle-item.complete > span:last-child { opacity: .5; }
|
||||
.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 > span { color: var(--season); font-size: 2rem; }
|
||||
.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) {
|
||||
.today-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.priority-summary { grid-template-columns: 1fr; }
|
||||
.room { grid-template-columns: 1fr; }
|
||||
.room-title { position: static; }
|
||||
}
|
||||
|
|
|
|||
44
tests/test_availability.js
Normal file
44
tests/test_availability.js
Normal 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");
|
||||
Loading…
Add table
Add a link
Reference in a new issue