diff --git a/README.md b/README.md index 22e54d9..807782c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/flake.nix b/flake.nix index 6ace621..9b5a58c 100644 --- a/flake.nix +++ b/flake.nix @@ -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 ''; }); diff --git a/package.nix b/package.nix index 23f453a..c94477e 100644 --- a/package.nix +++ b/package.nix @@ -2,7 +2,7 @@ stdenvNoCC.mkDerivation { pname = "stardew-tracker"; - version = "1.1.0"; + version = "1.2.0"; src = ./.; nativeBuildInputs = [ makeWrapper ]; diff --git a/static/app.js b/static/app.js index 6a3ac8c..c401e33 100644 --- a/static/app.js +++ b/static/app.js @@ -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(`Bundle ready`); + else if (status.seasonExclusive) badges.push(`Only ${seasonNames[state.season]}`); + else if (status.inSeason) badges.push(`In season`); + else if (status.seasonal) badges.push(`${seasonList(status.seasons)}`); + if (status.weather) badges.push(`${status.weather === "Rain" ? "Rain needed" : "Sunny only"}`); + return badges.length ? `${badges.join("")}` : ""; + } + 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 `

${escapeHtml(group.name)}

${group.choose ? `Choose ${group.required}` : ""}
${Math.min(progress.done, progress.required)}/${progress.required}
${items.map(entry => { const checked = Boolean(state.checks[entry.id]); - return ``; + 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 ``; }).join("")}
`; }).filter(Boolean).join(""); @@ -170,7 +212,16 @@ const roomDone = roomGroups.filter(progress => progress.complete).length; return `
${room.icon}

${escapeHtml(room.room)}

${roomDone} of ${room.groups.length} bundles ready

${groups}
`; }).filter(Boolean).join(""); - $("#bundle-list").innerHTML = html || `
✓

Everything in this view is done

Switch filters to review the whole Community Center.

`; + 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 || `
✓

${empty[0]}

${empty[1]}

`; + 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"); diff --git a/static/availability.js b/static/availability.js new file mode 100644 index 0000000..c1741b2 --- /dev/null +++ b/static/availability.js @@ -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 }; +}); diff --git a/static/index.html b/static/index.html index f39ca0f..676d755 100644 --- a/static/index.html +++ b/static/index.html @@ -6,7 +6,7 @@ - + Valley Yearbook @@ -80,11 +80,31 @@ +
+ + + +
- - + + + +
+

Availability follows normal sources for the selected season; the Traveling Cart can occasionally provide exceptions.

@@ -94,8 +114,9 @@ - - - + + + + diff --git a/static/styles.css b/static/styles.css index bd0562b..9bab5cd 100644 --- a/static/styles.css +++ b/static/styles.css @@ -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; } } diff --git a/tests/test_availability.js b/tests/test_availability.js new file mode 100644 index 0000000..7b6a0b1 --- /dev/null +++ b/tests/test_availability.js @@ -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");