Initial shared Stardew Valley tracker
This commit is contained in:
commit
a33f7a297d
14 changed files with 1171 additions and 0 deletions
292
static/app.js
Normal file
292
static/app.js
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
(() => {
|
||||
const data = window.VALLEY_DATA;
|
||||
const weekdays = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
|
||||
const seasonNames = { spring: "Spring", summer: "Summer", fall: "Fall", winter: "Winter" };
|
||||
const state = {
|
||||
year: Number(localStorage.getItem("valley-year")) || 1,
|
||||
season: localStorage.getItem("valley-season") || "spring",
|
||||
day: Number(localStorage.getItem("valley-day")) || 1,
|
||||
filter: "all",
|
||||
checks: {},
|
||||
revision: -1,
|
||||
saving: new Set()
|
||||
};
|
||||
|
||||
const $ = (selector, root = document) => root.querySelector(selector);
|
||||
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
|
||||
const escapeHtml = (value) => String(value).replace(/[&<>'"]/g, char => ({ "&": "&", "<": "<", ">": ">", "'": "'", '"': """ }[char]));
|
||||
const itemKey = crop => crop.sharedId || crop.id;
|
||||
|
||||
function saveDate() {
|
||||
localStorage.setItem("valley-year", state.year);
|
||||
localStorage.setItem("valley-season", state.season);
|
||||
localStorage.setItem("valley-day", state.day);
|
||||
}
|
||||
|
||||
function weekday(day = state.day) { return weekdays[(day - 1) % 7]; }
|
||||
|
||||
function setDate({ year = state.year, season = state.season, day = state.day }) {
|
||||
state.year = Math.max(1, Math.min(2, year));
|
||||
state.season = data.seasons.includes(season) ? season : "spring";
|
||||
state.day = Math.max(1, Math.min(28, day));
|
||||
saveDate();
|
||||
renderDate();
|
||||
renderToday();
|
||||
renderCrops();
|
||||
if (state.filter === "season") renderBundles();
|
||||
}
|
||||
|
||||
function moveDay(delta) {
|
||||
let index = data.seasons.indexOf(state.season);
|
||||
let day = state.day + delta;
|
||||
let year = state.year;
|
||||
if (day > 28) { day = 1; index += 1; }
|
||||
if (day < 1) { day = 28; index -= 1; }
|
||||
if (index > 3) { index = 0; year += 1; }
|
||||
if (index < 0) { index = 3; year -= 1; }
|
||||
if (year < 1 || year > 2) return;
|
||||
setDate({ year, season: data.seasons[index], day });
|
||||
}
|
||||
|
||||
function renderDate() {
|
||||
document.body.dataset.season = state.season;
|
||||
$("#season-title").textContent = seasonNames[state.season];
|
||||
$("#day-title").textContent = state.day;
|
||||
$("#weekday-title").textContent = `${weekday()} · Year ${state.year}`;
|
||||
$$("[data-year]").forEach(button => button.setAttribute("aria-pressed", String(Number(button.dataset.year) === state.year)));
|
||||
$$("[data-season]").forEach(button => button.setAttribute("aria-selected", String(button.dataset.season === state.season)));
|
||||
$("#day-grid").innerHTML = Array.from({ length: 28 }, (_, index) => {
|
||||
const day = index + 1;
|
||||
return `<button type="button" class="${day === state.day ? "selected" : ""}" data-day="${day}" aria-label="${seasonNames[state.season]} ${day}, ${weekday(day)}" aria-pressed="${day === state.day}"><small>${weekdays[index % 7].slice(0, 2)}</small>${day}</button>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function currentBirthday() {
|
||||
const entry = data.birthdays[state.season][state.day];
|
||||
if (!entry) return null;
|
||||
if (entry[1] && entry[1] !== state.year) return null;
|
||||
return entry[0];
|
||||
}
|
||||
|
||||
function currentEvent() {
|
||||
const entry = data.events[state.season][state.day];
|
||||
if (!entry) return null;
|
||||
if (entry[2] && entry[2] !== state.year) return null;
|
||||
return entry;
|
||||
}
|
||||
|
||||
function reminders() {
|
||||
const name = weekday();
|
||||
const result = [];
|
||||
if (name === "Friday" || name === "Sunday") result.push(["Traveling Cart", "Cindersap Forest, 6am–8pm. Check for off-season bundle items.", "cart"]);
|
||||
if (name === "Sunday") result.push(["The Queen of Sauce", "Watch TV for a new recipe during Years 1–2.", "tv"]);
|
||||
if (name === "Wednesday") result.push(["Recipe rerun", "The Queen of Sauce repeats a recipe you may have missed.", "tv"]);
|
||||
if (name === "Monday" && (state.year > 1 || state.season !== "spring" || state.day >= 2)) result.push(["New week", "Special Order boards refresh on Monday once unlocked.", "board"]);
|
||||
return result;
|
||||
}
|
||||
|
||||
function cropDeadlineCards() {
|
||||
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 `<article class="today-card ${kind}"><div class="card-label"><span></span>${escapeHtml(label)}</div><h3>${escapeHtml(title)}</h3><p>${escapeHtml(copy)}</p></article>`;
|
||||
}
|
||||
|
||||
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 (!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`;
|
||||
}
|
||||
|
||||
function renderCrops() {
|
||||
const crops = data.crops.filter(crop => crop.season === state.season && (!crop.year || crop.year === state.year));
|
||||
const rows = crops.map(crop => {
|
||||
const key = itemKey(crop);
|
||||
const checked = Boolean(state.checks[key]);
|
||||
const overdue = state.day > crop.last && !checked;
|
||||
const today = state.day === crop.last && !checked;
|
||||
return `<label class="crop-row ${checked ? "complete" : ""} ${overdue ? "overdue" : ""}">
|
||||
<input type="checkbox" data-check-id="${key}" ${checked ? "checked" : ""} ${state.saving.has(key) ? "disabled" : ""}>
|
||||
<span class="checkmark" aria-hidden="true"></span>
|
||||
<span class="crop-name"><strong>${escapeHtml(crop.name)}</strong><small>${escapeHtml(crop.purpose)}</small></span>
|
||||
<span class="grow-time">${crop.grow} days<small>to grow</small></span>
|
||||
<span class="plant-date ${today ? "today" : ""}">${overdue ? "Passed" : `${seasonNames[crop.season]} ${crop.last}`}<small>${overdue ? "for this season" : "plant by"}</small></span>
|
||||
</label>`;
|
||||
});
|
||||
$("#crop-list").innerHTML = rows.length ? rows.join("") : `<div class="empty-state"><span>❄</span><h3>No mandatory field crops</h3><p>Use Winter for the foraging bundle, mines, fishing, and any unfinished animal or artisan goods.</p></div>`;
|
||||
}
|
||||
|
||||
function groupProgress(group) {
|
||||
const done = group.items.filter(entry => state.checks[entry.id]).length;
|
||||
return { done, required: group.required, complete: done >= group.required };
|
||||
}
|
||||
|
||||
function bundleTotals() {
|
||||
return data.bundles.reduce((totals, room) => room.groups.reduce((inner, group) => {
|
||||
const { done, required } = groupProgress(group);
|
||||
inner.done += Math.min(done, required);
|
||||
inner.required += required;
|
||||
return inner;
|
||||
}, totals), { done: 0, required: 0 });
|
||||
}
|
||||
|
||||
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 (!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>`;
|
||||
}).join("")}</div>
|
||||
</article>`;
|
||||
}).filter(Boolean).join("");
|
||||
if (!groups) return "";
|
||||
const roomGroups = room.groups.map(groupProgress);
|
||||
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 totals = bundleTotals();
|
||||
const percent = totals.required ? Math.round(totals.done / totals.required * 100) : 0;
|
||||
$("#progress-label").textContent = `${totals.done} of ${totals.required} essentials`;
|
||||
$("#progress-bar").style.width = `${percent}%`;
|
||||
}
|
||||
|
||||
function setSyncStatus(mode, text) {
|
||||
const node = $("#sync-status");
|
||||
node.dataset.mode = mode;
|
||||
node.lastElementChild.textContent = text;
|
||||
}
|
||||
|
||||
function toast(message) {
|
||||
const node = $("#toast");
|
||||
node.textContent = message;
|
||||
node.classList.add("show");
|
||||
clearTimeout(toast.timer);
|
||||
toast.timer = setTimeout(() => node.classList.remove("show"), 2600);
|
||||
}
|
||||
|
||||
async function loadState({ quiet = false } = {}) {
|
||||
if (!quiet) setSyncStatus("loading", "Connecting…");
|
||||
try {
|
||||
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;
|
||||
renderCrops();
|
||||
renderBundles();
|
||||
}
|
||||
setSyncStatus("ready", "Shared progress synced");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setSyncStatus("error", "Sync unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
async function updateCheck(itemId, checked) {
|
||||
const previous = Boolean(state.checks[itemId]);
|
||||
state.checks[itemId] = checked;
|
||||
state.saving.add(itemId);
|
||||
renderCrops();
|
||||
renderBundles();
|
||||
setSyncStatus("loading", "Saving…");
|
||||
try {
|
||||
const response = await fetch("/api/checks", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ itemId, checked })
|
||||
});
|
||||
if (!response.ok) throw new Error(`Save failed (${response.status})`);
|
||||
const result = await response.json();
|
||||
state.revision = Math.max(state.revision, result.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.");
|
||||
} finally {
|
||||
state.saving.delete(itemId);
|
||||
renderCrops();
|
||||
renderBundles();
|
||||
}
|
||||
}
|
||||
|
||||
function installWebMcp() {
|
||||
const context = document.modelContext;
|
||||
if (!context?.registerTool) return;
|
||||
const knownIds = new Set(data.bundles.flatMap(room => room.groups.flatMap(group => group.items.map(entry => entry.id))));
|
||||
void Promise.resolve(context.registerTool({
|
||||
name: "get_valley_progress",
|
||||
title: "Get Valley progress",
|
||||
description: "Read the currently selected game date and shared Community Center checklist progress.",
|
||||
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
||||
annotations: { readOnlyHint: true, untrustedContentHint: false },
|
||||
execute: () => ({ year: state.year, season: state.season, day: state.day, progress: bundleTotals(), completedItemIds: Object.keys(state.checks).filter(id => state.checks[id]) })
|
||||
})).catch(console.error);
|
||||
void Promise.resolve(context.registerTool({
|
||||
name: "set_valley_checklist_item",
|
||||
title: "Update Valley checklist",
|
||||
description: "Check or uncheck one known Community Center item in the shared household plan.",
|
||||
inputSchema: { type: "object", properties: { itemId: { type: "string" }, checked: { type: "boolean" } }, required: ["itemId", "checked"], additionalProperties: false },
|
||||
annotations: { readOnlyHint: false, untrustedContentHint: false },
|
||||
async execute(input) {
|
||||
if (!input || !knownIds.has(input.itemId) || typeof input.checked !== "boolean") throw new Error("Unknown checklist item or invalid checked value");
|
||||
await updateCheck(input.itemId, input.checked);
|
||||
return { itemId: input.itemId, checked: Boolean(state.checks[input.itemId]), revision: state.revision };
|
||||
}
|
||||
})).catch(console.error);
|
||||
}
|
||||
|
||||
document.addEventListener("click", event => {
|
||||
const yearButton = event.target.closest("[data-year]");
|
||||
if (yearButton) setDate({ year: Number(yearButton.dataset.year) });
|
||||
const seasonButton = event.target.closest("[data-season]");
|
||||
if (seasonButton) setDate({ season: seasonButton.dataset.season });
|
||||
const dayButton = event.target.closest("[data-day]");
|
||||
if (dayButton) setDate({ day: Number(dayButton.dataset.day) });
|
||||
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)));
|
||||
renderBundles();
|
||||
}
|
||||
const navLink = event.target.closest(".section-nav a");
|
||||
if (navLink) {
|
||||
$$(".section-nav a").forEach(link => link.classList.toggle("active", link === navLink));
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("change", event => {
|
||||
const input = event.target.closest("[data-check-id]");
|
||||
if (input) updateCheck(input.dataset.checkId, input.checked);
|
||||
});
|
||||
|
||||
$("#previous-day").addEventListener("click", () => moveDay(-1));
|
||||
$("#next-day").addEventListener("click", () => moveDay(1));
|
||||
|
||||
renderDate();
|
||||
renderToday();
|
||||
renderCrops();
|
||||
renderBundles();
|
||||
loadState();
|
||||
setInterval(() => loadState({ quiet: true }), 4000);
|
||||
installWebMcp();
|
||||
})();
|
||||
116
static/data.js
Normal file
116
static/data.js
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
window.VALLEY_DATA = (() => {
|
||||
const birthdays = {
|
||||
spring: { 4: ["Kent (Year 2+)", 2], 7: ["Lewis"], 10: ["Vincent"], 14: ["Haley"], 18: ["Pam"], 20: ["Shane"], 26: ["Pierre"], 27: ["Emily"] },
|
||||
summer: { 4: ["Jas"], 8: ["Gus"], 10: ["Maru"], 13: ["Alex"], 17: ["Sam"], 19: ["Demetrius"], 22: ["Dwarf"], 24: ["Willy"], 26: ["Leo"] },
|
||||
fall: { 2: ["Penny"], 5: ["Elliott"], 11: ["Jodi"], 13: ["Abigail"], 15: ["Sandy"], 18: ["Marnie"], 21: ["Robin"], 24: ["George"] },
|
||||
winter: { 1: ["Krobus"], 3: ["Linus"], 7: ["Caroline"], 10: ["Sebastian"], 14: ["Harvey"], 17: ["Wizard"], 20: ["Evelyn"], 23: ["Leah"], 26: ["Clint"] }
|
||||
};
|
||||
|
||||
const events = {
|
||||
spring: {
|
||||
13: ["Egg Festival", "Pelican Town, 9am–2pm. Strawberry Seeds are the prize purchase."],
|
||||
15: ["Desert Festival · day 1", "10am–2am; the bus must be repaired."],
|
||||
16: ["Desert Festival · day 2", "10am–2am; the bus must be repaired."],
|
||||
17: ["Desert Festival · day 3", "10am–2am. A pot of gold also appears in Cindersap Forest."],
|
||||
24: ["Flower Dance", "Cindersap Forest, 9am–2pm."]
|
||||
},
|
||||
summer: {
|
||||
3: ["Railroad opens", "In Year 1, the overnight earthquake opens the railroad and spa.", 1],
|
||||
11: ["Luau", "The Beach, 9am–2pm. Bring a good ingredient for the soup."],
|
||||
12: ["Beach forage", "Extra forageables appear at the beach through Summer 14."],
|
||||
13: ["Beach forage", "Extra forageables appear at the beach through Summer 14."],
|
||||
14: ["Beach forage", "Last day of the extra beach forage window."],
|
||||
20: ["Trout Derby · day 1", "Cindersap Forest, 6:10am–2am."],
|
||||
21: ["Trout Derby · day 2", "Cindersap Forest, 6:10am–2am."],
|
||||
28: ["Dance of the Moonlight Jellies", "The Beach, 10pm–12am."]
|
||||
},
|
||||
fall: {
|
||||
8: ["Blackberry season", "Shake berry bushes through Fall 11."],
|
||||
9: ["Blackberry season", "Shake berry bushes through Fall 11."],
|
||||
10: ["Blackberry season", "Shake berry bushes through Fall 11."],
|
||||
11: ["Blackberry season", "Last day to shake blackberry bushes."],
|
||||
16: ["Stardew Valley Fair", "Pelican Town, 9am–3pm. Bring nine strong grange-display items."],
|
||||
27: ["Spirit’s Eve", "Pelican Town, 10pm–11:50pm."]
|
||||
},
|
||||
winter: {
|
||||
8: ["Festival of Ice", "Cindersap Forest, 9am–2pm."],
|
||||
12: ["SquidFest · day 1", "The Beach, 6:10am–2am."],
|
||||
13: ["SquidFest · day 2", "The Beach, 6:10am–2am."],
|
||||
15: ["Night Market · night 1", "The Beach, 5pm–2am. Check the traveling merchant and submarine."],
|
||||
16: ["Night Market · night 2", "The Beach, 5pm–2am."],
|
||||
17: ["Night Market · night 3", "The Beach, 5pm–2am."],
|
||||
25: ["Feast of the Winter Star", "Pelican Town, 9am–2pm. Bring your secret friend’s gift."],
|
||||
28: ["Year-end check", "Final day before the next spring. Winter 28, Year 2 leads into Grandpa’s evaluation.", 2]
|
||||
}
|
||||
};
|
||||
|
||||
const crops = [
|
||||
{ id: "crop-parsnip", name: "Parsnip", season: "spring", grow: 4, last: 24, purpose: "Spring Crops · also grow 5 gold-quality for Quality Crops" },
|
||||
{ id: "crop-green-bean", name: "Green Bean", season: "spring", grow: 10, last: 18, purpose: "Spring Crops · trellis" },
|
||||
{ id: "crop-potato", name: "Potato", season: "spring", grow: 6, last: 22, purpose: "Spring Crops" },
|
||||
{ id: "crop-cauliflower", name: "Cauliflower", season: "spring", grow: 12, last: 16, purpose: "Spring Crops" },
|
||||
{ id: "crop-blueberry", name: "Blueberry", season: "summer", grow: 13, last: 15, purpose: "Summer Crops" },
|
||||
{ id: "crop-melon", name: "Melon", season: "summer", grow: 12, last: 16, purpose: "Summer Crops · also grow 5 gold-quality for Quality Crops" },
|
||||
{ id: "crop-hot-pepper", name: "Hot Pepper", season: "summer", grow: 5, last: 23, purpose: "Summer Crops" },
|
||||
{ id: "crop-tomato", name: "Tomato", season: "summer", grow: 11, last: 17, purpose: "Summer Crops" },
|
||||
{ id: "crop-red-cabbage", name: "Red Cabbage", season: "summer", grow: 9, last: 19, year: 2, purpose: "Dye Bundle · Year 2 seed, or Traveling Cart in Year 1" },
|
||||
{ id: "crop-corn", name: "Corn", season: "summer", grow: 14, last: 14, purpose: "Fall Crops · grows through Fall; 5 gold-quality can count for Quality Crops" },
|
||||
{ id: "crop-poppy", name: "Poppy", season: "summer", grow: 7, last: 21, purpose: "Chef’s Bundle" },
|
||||
{ id: "crop-sunflower", name: "Sunflower", season: "summer", grow: 8, last: 20, purpose: "Dye Bundle · also grows in Fall" },
|
||||
{ id: "crop-wheat-10", name: "Wheat ×10", season: "summer", grow: 4, last: 24, purpose: "Fodder Bundle · also grows in Fall" },
|
||||
{ id: "tree-apple", name: "Apple Tree", season: "summer", grow: 28, last: 25, purpose: "Plant by Summer 25 to collect 3 apples by Fall 28" },
|
||||
{ id: "tree-pomegranate", name: "Pomegranate Tree", season: "summer", grow: 28, last: 27, purpose: "Plant by late Summer for an Enchanter’s Bundle fruit in Fall" },
|
||||
{ id: "crop-eggplant", name: "Eggplant", season: "fall", grow: 5, last: 23, purpose: "Fall Crops" },
|
||||
{ id: "crop-pumpkin", name: "Pumpkin", season: "fall", grow: 13, last: 15, purpose: "Fall Crops · also grow 5 gold-quality for Quality Crops" },
|
||||
{ id: "crop-yam", name: "Yam", season: "fall", grow: 10, last: 18, purpose: "Fall Crops" },
|
||||
{ id: "crop-corn-fall", sharedId: "crop-corn", name: "Corn", season: "fall", grow: 14, last: 14, purpose: "Fall Crops · skip if your Summer corn is still growing" },
|
||||
{ id: "crop-sunflower-fall", sharedId: "crop-sunflower", name: "Sunflower", season: "fall", grow: 8, last: 20, purpose: "Dye Bundle · skip if completed in Summer" },
|
||||
{ id: "crop-wheat-fall", sharedId: "crop-wheat-10", name: "Wheat ×10", season: "fall", grow: 4, last: 24, purpose: "Fodder Bundle · skip if completed in Summer" },
|
||||
{ id: "crop-winter-seeds", name: "Winter forage set", season: "winter", grow: 7, last: 21, purpose: "Optional: craft Winter Seeds after gathering the four Winter Foraging items" }
|
||||
];
|
||||
|
||||
const item = (id, label, meta, seasons = []) => ({ id, label, meta, seasons });
|
||||
const bundles = [
|
||||
{ room: "Crafts Room", icon: "⌂", groups: [
|
||||
{ name: "Spring Foraging", required: 4, items: [item("forage-wild-horseradish", "Wild Horseradish", "Spring forage", ["spring"]), item("forage-daffodil", "Daffodil", "Spring forage", ["spring"]), item("forage-leek", "Leek", "Spring forage", ["spring"]), item("forage-dandelion", "Dandelion", "Spring forage", ["spring"])] },
|
||||
{ name: "Summer Foraging", required: 3, items: [item("forage-grape", "Grape", "Summer forage", ["summer"]), item("forage-spice-berry", "Spice Berry", "Summer forage", ["summer"]), item("forage-sweet-pea", "Sweet Pea", "Summer forage", ["summer"])] },
|
||||
{ name: "Fall Foraging", required: 4, items: [item("forage-common-mushroom", "Common Mushroom", "Fall forage", ["fall"]), item("forage-wild-plum", "Wild Plum", "Fall forage", ["fall"]), item("forage-hazelnut", "Hazelnut", "Fall forage", ["fall"]), item("forage-blackberry", "Blackberry", "Fall forage; bushes Fall 8–11", ["fall"])] },
|
||||
{ name: "Winter Foraging", required: 4, items: [item("forage-winter-root", "Winter Root", "Dig soil/artifact spots", ["winter"]), item("forage-crystal-fruit", "Crystal Fruit", "Winter forage", ["winter"]), item("forage-snow-yam", "Snow Yam", "Dig soil/artifact spots", ["winter"]), item("forage-crocus", "Crocus", "Winter forage", ["winter"])] },
|
||||
{ name: "Construction", required: 3, items: [item("construction-wood-a", "Wood ×99", "Chop trees"), item("construction-wood-b", "Wood ×99", "A second stack"), item("construction-hardwood", "Hardwood ×10", "Large stumps / Secret Woods")] },
|
||||
{ name: "Exotic Foraging", required: 5, choose: true, items: [item("exotic-coconut", "Coconut", "Desert forage"), item("exotic-cactus-fruit", "Cactus Fruit", "Desert forage"), item("exotic-cave-carrot", "Cave Carrot", "Mine crates / soil"), item("exotic-red-mushroom", "Red Mushroom", "Mines / cave"), item("exotic-purple-mushroom", "Purple Mushroom", "Mines / cave"), item("exotic-maple-syrup", "Maple Syrup", "Tap maple tree"), item("exotic-oak-resin", "Oak Resin", "Tap oak tree"), item("exotic-pine-tar", "Pine Tar", "Tap pine tree"), item("exotic-morel", "Morel", "Secret Woods in Spring", ["spring"])] }
|
||||
]},
|
||||
{ room: "Pantry", icon: "◒", groups: [
|
||||
{ name: "Spring Crops", required: 4, items: [item("crop-parsnip", "Parsnip", "4 days · plant by Spring 24", ["spring"]), item("crop-green-bean", "Green Bean", "10 days · plant by Spring 18", ["spring"]), item("crop-cauliflower", "Cauliflower", "12 days · plant by Spring 16", ["spring"]), item("crop-potato", "Potato", "6 days · plant by Spring 22", ["spring"])] },
|
||||
{ name: "Summer Crops", required: 4, items: [item("crop-tomato", "Tomato", "11 days · plant by Summer 17", ["summer"]), item("crop-hot-pepper", "Hot Pepper", "5 days · plant by Summer 23", ["summer"]), item("crop-blueberry", "Blueberry", "13 days · plant by Summer 15", ["summer"]), item("crop-melon", "Melon", "12 days · plant by Summer 16", ["summer"])] },
|
||||
{ name: "Fall Crops", required: 4, items: [item("crop-corn", "Corn", "14 days · Summer/Fall", ["summer", "fall"]), item("crop-eggplant", "Eggplant", "5 days · plant by Fall 23", ["fall"]), item("crop-pumpkin", "Pumpkin", "13 days · plant by Fall 15", ["fall"]), item("crop-yam", "Yam", "10 days · plant by Fall 18", ["fall"])] },
|
||||
{ name: "Quality Crops", required: 3, choose: true, items: [item("quality-parsnip", "Gold Parsnip ×5", "Basic Fertilizer helps", ["spring"]), item("quality-melon", "Gold Melon ×5", "Basic Fertilizer helps", ["summer"]), item("quality-pumpkin", "Gold Pumpkin ×5", "Basic Fertilizer helps", ["fall"]), item("quality-corn", "Gold Corn ×5", "Summer/Fall", ["summer", "fall"])] },
|
||||
{ name: "Animal", required: 5, choose: true, items: [item("animal-large-milk", "Large Milk", "Happy cow"), item("animal-large-egg-brown", "Large Brown Egg", "Happy brown chicken"), item("animal-large-egg-white", "Large White Egg", "Happy white chicken"), item("animal-large-goat-milk", "Large Goat Milk", "Happy goat"), item("animal-wool", "Wool", "Sheep or rabbit"), item("animal-duck-egg", "Duck Egg", "Duck")] },
|
||||
{ name: "Artisan", required: 6, choose: true, items: [item("artisan-truffle-oil", "Truffle Oil", "Oil Maker"), item("artisan-cloth", "Cloth", "Loom / recycling"), item("artisan-goat-cheese", "Goat Cheese", "Cheese Press"), item("artisan-cheese", "Cheese", "Cheese Press"), item("artisan-honey", "Honey", "Bee House"), item("artisan-jelly", "Jelly", "Preserves Jar"), item("artisan-apple", "Apple", "Fall fruit tree", ["fall"]), item("artisan-apricot", "Apricot", "Spring fruit tree", ["spring"]), item("artisan-orange", "Orange", "Summer fruit tree", ["summer"]), item("artisan-peach", "Peach", "Summer fruit tree", ["summer"]), item("artisan-pomegranate", "Pomegranate", "Fall fruit tree", ["fall"]), item("artisan-cherry", "Cherry", "Spring fruit tree", ["spring"])] }
|
||||
]},
|
||||
{ room: "Fish Tank", icon: "≈", groups: [
|
||||
{ name: "River Fish", required: 4, items: [item("fish-sunfish", "Sunfish", "River · 6am–7pm · sunny", ["spring", "summer"]), item("fish-catfish", "Catfish", "River · 6am–12am · rain", ["spring", "fall"]), item("fish-shad", "Shad", "River · 9am–2am · rain", ["spring", "summer", "fall"]), item("fish-tiger-trout", "Tiger Trout", "River · 6am–7pm", ["fall", "winter"])] },
|
||||
{ name: "Lake Fish", required: 4, items: [item("fish-largemouth-bass", "Largemouth Bass", "Mountain Lake · 6am–7pm", ["spring", "summer", "fall", "winter"]), item("fish-carp", "Carp", "Mountain Lake · any time", ["spring", "summer", "fall", "winter"]), item("fish-bullhead", "Bullhead", "Mountain Lake · any time", ["spring", "summer", "fall", "winter"]), item("fish-sturgeon", "Sturgeon", "Mountain Lake · 6am–7pm", ["summer", "winter"])] },
|
||||
{ name: "Ocean Fish", required: 4, items: [item("fish-sardine", "Sardine", "Ocean · 6am–7pm", ["spring", "fall", "winter"]), item("fish-tuna", "Tuna", "Ocean · 6am–7pm", ["summer", "winter"]), item("fish-red-snapper", "Red Snapper", "Ocean · 6am–7pm · rain", ["summer", "fall"]), item("fish-tilapia", "Tilapia", "Ocean · 6am–2pm", ["summer", "fall"])] },
|
||||
{ name: "Night Fishing", required: 3, items: [item("fish-walleye", "Walleye", "River/Lake · 12pm–2am · rain", ["fall"]), item("fish-bream", "Bream", "River · 6pm–2am", ["spring", "summer", "fall", "winter"]), item("fish-eel", "Eel", "Ocean · 4pm–2am · rain", ["spring", "fall"])] },
|
||||
{ name: "Specialty Fish", required: 4, items: [item("fish-pufferfish", "Pufferfish", "Ocean · 12pm–4pm · sunny", ["summer"]), item("fish-ghostfish", "Ghostfish", "Mines floors 20/60"), item("fish-sandfish", "Sandfish", "Desert pond · 6am–8pm"), item("fish-woodskip", "Woodskip", "Secret Woods · any time")] },
|
||||
{ name: "Crab Pot", required: 5, choose: true, items: [item("crab-lobster", "Lobster", "Ocean crab pot"), item("crab-crayfish", "Crayfish", "Freshwater crab pot"), item("crab-crab", "Crab", "Ocean crab pot / rock crab"), item("crab-cockle", "Cockle", "Ocean crab pot / beach"), item("crab-mussel", "Mussel", "Ocean crab pot / beach"), item("crab-shrimp", "Shrimp", "Ocean crab pot"), item("crab-snail", "Snail", "Freshwater crab pot"), item("crab-periwinkle", "Periwinkle", "Freshwater crab pot"), item("crab-oyster", "Oyster", "Ocean crab pot / beach"), item("crab-clam", "Clam", "Ocean crab pot / beach")] }
|
||||
]},
|
||||
{ room: "Boiler Room", icon: "♨", groups: [
|
||||
{ name: "Blacksmith’s", required: 3, items: [item("bar-copper", "Copper Bar", "Smelt 5 ore"), item("bar-iron", "Iron Bar", "Smelt 5 ore"), item("bar-gold", "Gold Bar", "Smelt 5 ore")] },
|
||||
{ name: "Geologist’s", required: 4, items: [item("mineral-quartz", "Quartz", "Mines"), item("mineral-earth-crystal", "Earth Crystal", "Mines floors 1–39"), item("mineral-frozen-tear", "Frozen Tear", "Mines floors 40–79"), item("mineral-fire-quartz", "Fire Quartz", "Mines floors 80+")] },
|
||||
{ name: "Adventurer’s", required: 2, choose: true, items: [item("monster-slime", "Slime ×99", "Slimes"), item("monster-bat-wing", "Bat Wing ×10", "Bats"), item("monster-solar-essence", "Solar Essence", "Ghost / Squid Kid"), item("monster-void-essence", "Void Essence", "Shadow Brute / Shaman")] }
|
||||
]},
|
||||
{ room: "Bulletin Board", icon: "✦", groups: [
|
||||
{ name: "Chef’s", required: 6, items: [item("chef-maple-syrup", "Maple Syrup", "Tap maple tree"), item("chef-fiddlehead", "Fiddlehead Fern", "Secret Woods", ["summer"]), item("chef-truffle", "Truffle", "Adult pig outdoors"), item("crop-poppy", "Poppy", "7 days · plant by Summer 21", ["summer"]), item("chef-maki-roll", "Maki Roll", "Cook / Saloon / Krobus"), item("chef-fried-egg", "Fried Egg", "Cook one egg")] },
|
||||
{ name: "Dye", required: 6, items: [item("dye-red-mushroom", "Red Mushroom", "Mines / cave"), item("dye-sea-urchin", "Sea Urchin", "Beach tide pools"), item("crop-sunflower", "Sunflower", "8 days · Summer/Fall", ["summer", "fall"]), item("dye-duck-feather", "Duck Feather", "Happy duck"), item("dye-aquamarine", "Aquamarine", "Mines floors 40+"), item("crop-red-cabbage", "Red Cabbage", "Year 2 or Year 1 Traveling Cart", ["summer"])] },
|
||||
{ name: "Field Research", required: 4, items: [item("field-purple-mushroom", "Purple Mushroom", "Mines / cave"), item("field-nautilus-shell", "Nautilus Shell", "Beach forage", ["winter"]), item("field-chub", "Chub", "Mountain Lake / forest river", ["spring", "summer", "fall", "winter"]), item("field-frozen-geode", "Frozen Geode", "Mines floors 40–79")] },
|
||||
{ name: "Fodder", required: 3, items: [item("crop-wheat-10", "Wheat ×10", "4 days · Summer/Fall", ["summer", "fall"]), item("fodder-hay", "Hay ×10", "Silo / Marnie"), item("fodder-apple", "Apple ×3", "Fall fruit tree", ["fall"])] },
|
||||
{ name: "Enchanter’s", required: 4, items: [item("enchanter-oak-resin", "Oak Resin", "Tap oak tree"), item("enchanter-wine", "Wine", "Keg"), item("enchanter-rabbits-foot", "Rabbit’s Foot", "Happy rabbit / Serpent"), item("enchanter-pomegranate", "Pomegranate", "Fall fruit tree", ["fall"])] }
|
||||
]},
|
||||
{ room: "Vault", icon: "◇", groups: [
|
||||
{ name: "Cash Bundles", required: 4, items: [item("vault-2500", "2,500g", "Purchase at the Vault"), item("vault-5000", "5,000g", "Purchase at the Vault"), item("vault-10000", "10,000g", "Purchase at the Vault"), item("vault-25000", "25,000g", "Purchase at the Vault · total 42,500g")] }
|
||||
]}
|
||||
];
|
||||
|
||||
return { birthdays, events, crops, bundles, seasons: ["spring", "summer", "fall", "winter"] };
|
||||
})();
|
||||
6
static/favicon.svg
Normal file
6
static/favicon.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="7" fill="#132720"/>
|
||||
<path d="M7 7h8v8H7zm10 0h8v8h-8zM7 17h8v8H7zm10 0h8v8h-8z" fill="#f1c96d"/>
|
||||
<path d="M9 9h4v4H9z" fill="#79b56b"/><path d="M19 9h4v4h-4z" fill="#f08b5d"/>
|
||||
<path d="M9 19h4v4H9z" fill="#c77a48"/><path d="M19 19h4v4h-4z" fill="#b9d7eb"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 366 B |
100
static/index.html
Normal file
100
static/index.html
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<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">
|
||||
<title>Valley Yearbook</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<header class="topbar">
|
||||
<a class="brand" href="#today" aria-label="Valley Yearbook home">
|
||||
<span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i><i></i></span>
|
||||
<span><strong>Valley Yearbook</strong><small>Two years, one shared plan</small></span>
|
||||
</a>
|
||||
<div class="sync-status" id="sync-status" role="status" aria-live="polite">
|
||||
<span class="sync-dot"></span><span>Connecting…</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="date-deck" aria-labelledby="date-title">
|
||||
<div class="date-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Farm calendar</p>
|
||||
<h1 id="date-title"><span id="season-title">Spring</span> <span id="day-title">1</span></h1>
|
||||
<p id="weekday-title">Monday · Year 1</p>
|
||||
</div>
|
||||
<div class="date-arrows" aria-label="Change day">
|
||||
<button id="previous-day" type="button" aria-label="Previous day">−</button>
|
||||
<button id="next-day" type="button" aria-label="Next day">+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="year-switch" role="group" aria-label="Year">
|
||||
<button type="button" data-year="1" aria-pressed="true">Year one</button>
|
||||
<button type="button" data-year="2" aria-pressed="false">Year two</button>
|
||||
</div>
|
||||
|
||||
<div class="season-tabs" role="tablist" aria-label="Season">
|
||||
<button type="button" role="tab" data-season="spring" aria-selected="true"><span>✿</span>Spring</button>
|
||||
<button type="button" role="tab" data-season="summer" aria-selected="false"><span>☀</span>Summer</button>
|
||||
<button type="button" role="tab" data-season="fall" aria-selected="false"><span>◆</span>Fall</button>
|
||||
<button type="button" role="tab" data-season="winter" aria-selected="false"><span>✦</span>Winter</button>
|
||||
</div>
|
||||
|
||||
<div class="day-grid" id="day-grid" aria-label="Day of season"></div>
|
||||
</section>
|
||||
|
||||
<nav class="section-nav" aria-label="Planner sections">
|
||||
<a href="#today" class="active">Today</a>
|
||||
<a href="#crops">Planting plan</a>
|
||||
<a href="#bundles">Community Center</a>
|
||||
</nav>
|
||||
|
||||
<section id="today" class="content-section today-section" aria-labelledby="today-heading">
|
||||
<div class="section-heading">
|
||||
<div><p class="eyebrow">At a glance</p><h2 id="today-heading">Today in the valley</h2></div>
|
||||
<p id="today-summary"></p>
|
||||
</div>
|
||||
<div id="today-cards" class="today-grid"></div>
|
||||
</section>
|
||||
|
||||
<section id="crops" class="content-section" aria-labelledby="crops-heading">
|
||||
<div class="section-heading">
|
||||
<div><p class="eyebrow">No Speed-Gro assumed</p><h2 id="crops-heading">Planting plan</h2></div>
|
||||
<p>Last safe dates assume the crop is watered on planting day.</p>
|
||||
</div>
|
||||
<div id="crop-list" class="crop-list"></div>
|
||||
</section>
|
||||
|
||||
<section id="bundles" class="content-section" aria-labelledby="bundles-heading">
|
||||
<div class="section-heading bundle-heading">
|
||||
<div><p class="eyebrow">Standard bundles</p><h2 id="bundles-heading">Community Center</h2></div>
|
||||
<div class="progress-cluster">
|
||||
<span id="progress-label">0 of 0 essentials</span>
|
||||
<div class="progress-track" aria-hidden="true"><i id="progress-bar"></i></div>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
<div id="bundle-list" class="bundle-list"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p>Unofficial fan-made planner for Stardew Valley 1.6. Calendar and standard-bundle details are based on the <a href="https://stardewvalleywiki.com/Calendar" rel="noreferrer">Stardew Valley Wiki</a>.</p>
|
||||
</footer>
|
||||
</div>
|
||||
<div id="toast" class="toast" role="alert" aria-live="assertive"></div>
|
||||
<script src="/data.js"></script>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
188
static/styles.css
Normal file
188
static/styles.css
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
:root {
|
||||
color-scheme: dark;
|
||||
--ink: #f8f1dc;
|
||||
--muted: #aebbb2;
|
||||
--deep: #0c1814;
|
||||
--panel: #132720;
|
||||
--panel-2: #193229;
|
||||
--line: rgba(242, 232, 200, .13);
|
||||
--accent: #f1c96d;
|
||||
--season: #79b56b;
|
||||
--season-soft: rgba(121, 181, 107, .16);
|
||||
--danger: #f08b5d;
|
||||
--radius: 18px;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
body[data-season="summer"] { --season: #f0b84f; --season-soft: rgba(240, 184, 79, .16); }
|
||||
body[data-season="fall"] { --season: #d27849; --season-soft: rgba(210, 120, 73, .17); }
|
||||
body[data-season="winter"] { --season: #86bcd8; --season-soft: rgba(134, 188, 216, .16); }
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
background:
|
||||
radial-gradient(circle at 50% -10%, rgba(55, 107, 85, .23), transparent 40rem),
|
||||
var(--deep);
|
||||
color: var(--ink);
|
||||
min-height: 100vh;
|
||||
line-height: 1.5;
|
||||
}
|
||||
button, input { font: inherit; }
|
||||
button, a { -webkit-tap-highlight-color: transparent; }
|
||||
button { color: inherit; }
|
||||
a { color: inherit; }
|
||||
.app-shell { width: min(1180px, calc(100% - 32px)); margin: 0 auto; }
|
||||
|
||||
.topbar {
|
||||
min-height: 84px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 12px; text-decoration: none; }
|
||||
.brand strong { display: block; font-family: Georgia, serif; font-size: 1.18rem; letter-spacing: .01em; }
|
||||
.brand small { display: block; color: var(--muted); font-size: .78rem; margin-top: 1px; }
|
||||
.brand-mark { width: 34px; height: 34px; display: grid; grid-template-columns: 1fr 1fr; gap: 3px; padding: 5px; background: var(--ink); border-radius: 10px; transform: rotate(-2deg); }
|
||||
.brand-mark i { display: block; border-radius: 2px; background: #79b56b; }
|
||||
.brand-mark i:nth-child(2) { background: #f0b84f; }
|
||||
.brand-mark i:nth-child(3) { background: #d27849; }
|
||||
.brand-mark i:nth-child(4) { background: #86bcd8; }
|
||||
.sync-status { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: .84rem; white-space: nowrap; }
|
||||
.sync-dot { width: 8px; height: 8px; border-radius: 50%; background: #87958c; box-shadow: 0 0 0 4px rgba(135,149,140,.1); }
|
||||
.sync-status[data-mode="ready"] .sync-dot { background: #7bc47f; box-shadow: 0 0 0 4px rgba(123,196,127,.12); }
|
||||
.sync-status[data-mode="error"] .sync-dot { background: var(--danger); box-shadow: 0 0 0 4px rgba(240,139,93,.12); }
|
||||
|
||||
.date-deck { padding: 44px 0 30px; }
|
||||
.date-heading { display: flex; justify-content: space-between; align-items: end; gap: 20px; }
|
||||
.eyebrow { margin: 0 0 7px; text-transform: uppercase; letter-spacing: .16em; color: var(--season); font-weight: 800; font-size: .74rem; }
|
||||
h1, h2, h3, h4, p { margin-top: 0; }
|
||||
h1 { margin-bottom: 3px; font: 700 clamp(2.8rem, 8vw, 5.8rem)/.92 Georgia, serif; letter-spacing: -.045em; }
|
||||
.date-heading > div > p:last-child { margin: 13px 0 0; color: var(--muted); font-size: 1rem; }
|
||||
.date-arrows { display: flex; gap: 8px; }
|
||||
.date-arrows button { width: 48px; height: 48px; border: 1px solid var(--line); border-radius: 50%; background: var(--panel); cursor: pointer; font-size: 1.4rem; transition: .18s ease; }
|
||||
.date-arrows button:hover { border-color: var(--season); background: var(--season-soft); transform: translateY(-2px); }
|
||||
|
||||
.year-switch { display: inline-flex; margin: 30px 0 18px; padding: 4px; background: var(--panel); border: 1px solid var(--line); border-radius: 999px; }
|
||||
.year-switch button, .bundle-toolbar button { border: 0; background: transparent; padding: 9px 16px; border-radius: 999px; color: var(--muted); cursor: pointer; font-weight: 700; font-size: .88rem; }
|
||||
.year-switch button[aria-pressed="true"], .bundle-toolbar button[aria-pressed="true"] { background: var(--ink); color: var(--deep); }
|
||||
.season-tabs { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
|
||||
.season-tabs button { border: 1px solid var(--line); background: var(--panel); padding: 14px 12px; border-radius: 13px; cursor: pointer; color: var(--muted); font-weight: 750; transition: .18s ease; }
|
||||
.season-tabs button span { margin-right: 7px; }
|
||||
.season-tabs button[aria-selected="true"] { color: var(--ink); border-color: var(--season); background: var(--season-soft); }
|
||||
.day-grid { display: grid; grid-template-columns: repeat(14, 1fr); gap: 6px; margin-top: 12px; }
|
||||
.day-grid button { aspect-ratio: 1.1; min-width: 0; border: 1px solid var(--line); background: rgba(19,39,32,.65); color: var(--muted); border-radius: 10px; cursor: pointer; font-weight: 800; display: flex; flex-direction: column; align-items: center; justify-content: center; }
|
||||
.day-grid button small { font-size: .58rem; font-weight: 800; opacity: .6; line-height: 1; margin-bottom: 2px; }
|
||||
.day-grid button:hover { border-color: rgba(241,201,109,.45); color: var(--ink); }
|
||||
.day-grid button.selected { color: var(--deep); background: var(--season); border-color: var(--season); box-shadow: 0 6px 18px rgba(0,0,0,.2); }
|
||||
|
||||
.section-nav { position: sticky; top: 0; z-index: 10; display: flex; gap: 8px; background: rgba(12,24,20,.88); backdrop-filter: blur(14px); border-block: 1px solid var(--line); padding: 10px 0; }
|
||||
.section-nav a { text-decoration: none; padding: 8px 13px; border-radius: 999px; color: var(--muted); font-size: .9rem; font-weight: 750; }
|
||||
.section-nav a:hover, .section-nav a.active { color: var(--ink); background: var(--panel-2); }
|
||||
|
||||
.content-section { padding: 62px 0; scroll-margin-top: 60px; border-bottom: 1px solid var(--line); }
|
||||
.section-heading { display: flex; justify-content: space-between; align-items: end; gap: 30px; margin-bottom: 24px; }
|
||||
.section-heading h2 { margin: 0; font: 700 clamp(2rem, 4vw, 3.1rem)/1 Georgia, serif; letter-spacing: -.025em; }
|
||||
.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; }
|
||||
.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; }
|
||||
.card-label { display: flex; align-items: center; gap: 8px; color: var(--season); text-transform: uppercase; letter-spacing: .12em; font-size: .68rem; font-weight: 850; }
|
||||
.card-label span { width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
|
||||
.today-card.birthday { --season: #f1c96d; }
|
||||
.today-card.deadline { --season: #f08b5d; }
|
||||
.today-card.event { --season: #86bcd8; }
|
||||
|
||||
.crop-list { border: 1px solid var(--line); background: var(--panel); border-radius: var(--radius); overflow: hidden; }
|
||||
.crop-row { display: grid; grid-template-columns: 24px minmax(0, 1fr) 90px 110px; align-items: center; gap: 14px; padding: 16px 18px; border-bottom: 1px solid var(--line); cursor: pointer; transition: background .15s ease; }
|
||||
.crop-row:last-child { border: 0; }
|
||||
.crop-row:hover { background: rgba(255,255,255,.025); }
|
||||
.crop-row input, .bundle-item input { position: absolute; opacity: 0; pointer-events: none; }
|
||||
.checkmark { width: 22px; height: 22px; border: 1.5px solid #61746a; border-radius: 7px; display: grid; place-items: center; transition: .15s ease; }
|
||||
.complete .checkmark { background: var(--season); border-color: var(--season); }
|
||||
.complete .checkmark::after { content: "✓"; color: var(--deep); font-weight: 950; font-size: .86rem; }
|
||||
.crop-name strong, .bundle-item strong { display: block; font-size: .96rem; }
|
||||
.crop-name small, .bundle-item small, .grow-time small, .plant-date small { display: block; color: var(--muted); font-size: .76rem; font-weight: 500; margin-top: 2px; }
|
||||
.grow-time, .plant-date { color: var(--ink); font-weight: 800; font-size: .88rem; text-align: right; }
|
||||
.plant-date.today { color: var(--accent); }
|
||||
.crop-row.overdue .plant-date { color: var(--danger); }
|
||||
.crop-row.complete { opacity: .52; }
|
||||
.crop-row.complete .crop-name strong { text-decoration: line-through; }
|
||||
|
||||
.bundle-heading { align-items: center; }
|
||||
.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; }
|
||||
.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; }
|
||||
.room-title h3 { margin: 2px 0 2px; font: 700 1.18rem Georgia, serif; }
|
||||
.room-title p { margin: 0; color: var(--muted); font-size: .76rem; }
|
||||
.room-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
||||
.bundle-card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; }
|
||||
.bundle-card > header { display: flex; justify-content: space-between; align-items: center; padding: 16px 17px 13px; border-bottom: 1px solid var(--line); }
|
||||
.bundle-card h4 { margin: 0; font-size: .95rem; }
|
||||
.bundle-card header small { display: block; color: var(--accent); font-size: .7rem; margin-top: 2px; }
|
||||
.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:hover { background: rgba(255,255,255,.025); }
|
||||
.bundle-item.complete > span:last-child { opacity: .5; }
|
||||
.bundle-item.complete strong { text-decoration: line-through; }
|
||||
.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; }
|
||||
.empty-state p { margin: 0 auto; color: var(--muted); max-width: 32rem; }
|
||||
|
||||
footer { padding: 28px 0 48px; color: var(--muted); font-size: .76rem; }
|
||||
footer p { margin: 0; }
|
||||
footer a { text-underline-offset: 3px; }
|
||||
.toast { position: fixed; left: 50%; bottom: 24px; z-index: 20; transform: translate(-50%, 30px); opacity: 0; pointer-events: none; width: min(420px, calc(100% - 32px)); padding: 13px 16px; background: var(--ink); color: var(--deep); border-radius: 12px; font-weight: 700; text-align: center; box-shadow: 0 14px 40px rgba(0,0,0,.35); transition: .2s ease; }
|
||||
.toast.show { opacity: 1; transform: translate(-50%, 0); }
|
||||
|
||||
@media (max-width: 840px) {
|
||||
.today-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.room { grid-template-columns: 1fr; }
|
||||
.room-title { position: static; }
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.app-shell { width: min(100% - 22px, 1180px); }
|
||||
.topbar { min-height: 72px; }
|
||||
.brand small { display: none; }
|
||||
.sync-status { font-size: .74rem; }
|
||||
.date-deck { padding-top: 32px; }
|
||||
.date-heading { align-items: center; }
|
||||
.date-arrows button { width: 42px; height: 42px; }
|
||||
.season-tabs { grid-template-columns: repeat(2, 1fr); }
|
||||
.day-grid { grid-template-columns: repeat(7, 1fr); }
|
||||
.section-nav { overflow-x: auto; margin-inline: -11px; padding-inline: 11px; }
|
||||
.section-nav a { white-space: nowrap; }
|
||||
.content-section { padding: 46px 0; }
|
||||
.section-heading { align-items: start; flex-direction: column; gap: 10px; }
|
||||
.section-heading > p { text-align: left; }
|
||||
.today-grid, .room-grid { grid-template-columns: 1fr; }
|
||||
.today-card { min-height: 155px; }
|
||||
.crop-row { grid-template-columns: 24px 1fr 94px; gap: 11px; }
|
||||
.grow-time { display: none; }
|
||||
.crop-name small { max-width: 31ch; }
|
||||
.bundle-heading { align-items: stretch; }
|
||||
.progress-cluster { width: 100%; text-align: left; }
|
||||
.bundle-toolbar { display: flex; overflow-x: auto; }
|
||||
.bundle-toolbar button { flex: 0 0 auto; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html { scroll-behavior: auto; }
|
||||
*, *::before, *::after { transition-duration: .01ms !important; }
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue