Highlight seasonal Community Center priorities
This commit is contained in:
parent
8d5a94d7fa
commit
d2c7f004ea
8 changed files with 199 additions and 16 deletions
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue