Add birthday previews and restore checklist taps

This commit is contained in:
Codex 2026-10-05 19:54:15 +01:00
parent d2c7f004ea
commit aaa3013890
11 changed files with 146 additions and 27 deletions

View file

@ -2,6 +2,7 @@
const data = window.VALLEY_DATA;
const sync = window.ValleySync;
const availability = window.ValleyAvailability;
const calendar = window.ValleyCalendar;
const weekdays = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
const seasonNames = { spring: "Spring", summer: "Summer", fall: "Fall", winter: "Winter" };
const state = {
@ -59,15 +60,19 @@
$$("[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>`;
const birthday = birthdayOn(day);
const label = `${seasonNames[state.season]} ${day}, ${weekday(day)}${birthday ? `, ${birthday}'s birthday` : ""}`;
const marker = birthday ? `<span class="birthday-marker" title="${escapeHtml(`${birthday}'s birthday`)}" aria-hidden="true">🎂</span>` : "";
return `<button type="button" class="${day === state.day ? "selected" : ""}" data-day="${day}" aria-label="${escapeHtml(label)}" aria-pressed="${day === state.day}"><small>${weekdays[index % 7].slice(0, 2)}</small><span class="day-number">${day}</span>${marker}</button>`;
}).join("");
}
function birthdayOn(day) {
return calendar.birthdayOn(data.birthdays, state.season, day, state.year);
}
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];
return birthdayOn(state.day);
}
function currentEvent() {
@ -96,9 +101,19 @@
return `daily-${state.year}-${state.season}-${state.day}-${kind}-${slug}`;
}
function card(kind, label, title, copy, itemId = null) {
function giftGuide(name) {
const profile = data.giftProfiles[name];
if (!profile) return "";
const giftList = entries => entries.map(entry => `<li>${escapeHtml(entry)}</li>`).join("");
return `<div class="gift-guide" aria-label="Gift preferences for ${escapeHtml(name)}">
<div><strong><span aria-hidden="true">♥</span> Loves</strong><ul>${giftList(profile.loves)}</ul></div>
<div><strong><span aria-hidden="true">×</span> Avoid</strong><ul>${giftList(profile.avoid)}</ul></div>
</div>`;
}
function card(kind, label, title, copy, itemId = null, extra = "") {
const checked = itemId ? Boolean(state.checks[itemId]) : false;
const content = `${itemId ? `<input type="checkbox" data-check-id="${itemId}" ${checked ? "checked" : ""} ${state.saving.has(itemId) ? "disabled" : ""}><span class="checkmark" aria-hidden="true"></span>` : ""}<div class="card-label"><span></span>${escapeHtml(label)}</div><h3>${escapeHtml(title)}</h3><p>${escapeHtml(copy)}</p>`;
const content = `${itemId ? `<input type="checkbox" data-check-id="${itemId}" aria-label="Mark ${escapeHtml(title)} complete" ${checked ? "checked" : ""} ${state.saving.has(itemId) ? "disabled" : ""}><span class="checkmark" aria-hidden="true"></span>` : ""}<div class="card-label"><span></span>${escapeHtml(label)}</div><h3>${escapeHtml(title)}</h3><p>${escapeHtml(copy)}</p>${extra}`;
return itemId
? `<label class="today-card ${kind} ${checked ? "complete" : ""}">${content}</label>`
: `<article class="today-card ${kind}">${content}</article>`;
@ -108,7 +123,7 @@
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.", dailyId("birthday", birthday)));
if (birthday) cards.push(card("birthday", "Birthday", birthday, "A birthday gift gives 8× the usual friendship effect.", dailyId("birthday", birthday), giftGuide(birthday)));
if (event) cards.push(card("event", "Event", event[0], event[1], dailyId("event", event[0])));
reminders().forEach(([title, copy]) => cards.push(card("reminder", "Reminder", title, copy, dailyId("reminder", title))));
cropDeadlineCards().forEach(crop => cards.push(card("deadline", "Plant by today", crop.name, crop.purpose, itemKey(crop))));
@ -126,7 +141,7 @@
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" : ""}>
<input type="checkbox" data-check-id="${key}" aria-label="Mark ${escapeHtml(crop.name)} complete" ${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>
@ -203,7 +218,7 @@
const checked = Boolean(state.checks[entry.id]);
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>`;
return `<label class="bundle-item ${classes}"><input type="checkbox" data-check-id="${entry.id}" aria-label="Mark ${escapeHtml(entry.label)} complete" ${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("");

11
static/calendar.js Normal file
View file

@ -0,0 +1,11 @@
(function exposeCalendar(root, factory) {
const api = factory();
if (typeof module === "object" && module.exports) module.exports = api;
if (root) root.ValleyCalendar = api;
})(typeof window === "undefined" ? globalThis : window, () => ({
birthdayOn(birthdays, season, day, year) {
const entry = birthdays?.[season]?.[day];
if (!entry || (entry[1] && entry[1] !== year)) return null;
return entry[0];
}
}));

View file

@ -1,11 +1,50 @@
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"] },
spring: { 4: ["Kent", 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"] }
};
// A short, practical gift guide for birthday cards: selected loved gifts
// plus character-specific disliked or hated gifts that are safest to avoid.
const giftProfiles = {
Kent: { loves: ["Fiddlehead Risotto", "Roasted Hazelnuts"], avoid: ["Holly", "Sashimi"] },
Lewis: { loves: ["Autumn's Bounty", "Glazed Yams", "Hot Pepper"], avoid: ["Holly", "Quartz"] },
Vincent: { loves: ["Grape", "Pink Cake", "Snail"], avoid: ["Clay", "Triple Shot Espresso"] },
Haley: { loves: ["Coconut", "Pink Cake", "Sunflower"], avoid: ["Prismatic Shard", "Wild Horseradish"] },
Pam: { loves: ["Beer", "Pale Ale", "Parsnip"], avoid: ["Holly", "Octopus"] },
Shane: { loves: ["Beer", "Hot Pepper", "Pizza"], avoid: ["Pickles", "Quartz"] },
Pierre: { loves: ["Fried Calamari", "Price Catalogue"], avoid: ["Corn", "Tortilla"] },
Emily: { loves: ["Amethyst", "Cloth", "Wool"], avoid: ["Fish Taco", "Sashimi"] },
Jas: { loves: ["Fairy Rose", "Pink Cake", "Plum Pudding"], avoid: ["Clay", "Triple Shot Espresso"] },
Gus: { loves: ["Diamond", "Fish Taco", "Orange"], avoid: ["Coleslaw", "Holly"] },
Maru: { loves: ["Battery Pack", "Cauliflower", "Strawberry"], avoid: ["Holly", "Pickles"] },
Alex: { loves: ["Complete Breakfast", "Salmon Dinner"], avoid: ["Holly", "Quartz"] },
Sam: { loves: ["Cactus Fruit", "Maple Bar", "Pizza"], avoid: ["Coal", "Pickles"] },
Demetrius: { loves: ["Bean Hotpot", "Ice Cream", "Strawberry"], avoid: ["Holly"] },
Dwarf: { loves: ["Amethyst", "Jade", "Omni Geode"], avoid: ["Eggs", "Milk"] },
Willy: { loves: ["Catfish", "Pumpkin", "Sturgeon"], avoid: ["Dirt", "Green Frog Egg"] },
Leo: { loves: ["Duck Feather", "Mango", "Poi"], avoid: ["Beer", "Holly"] },
Penny: { loves: ["Melon", "Poppy", "Sandfish"], avoid: ["Rabbit's Foot", "Wine"] },
Elliott: { loves: ["Crab Cakes", "Lobster", "Pomegranate"], avoid: ["Quartz", "Sea Cucumber"] },
Jodi: { loves: ["Chocolate Cake", "Pancakes", "Rhubarb Pie"], avoid: ["Daffodil", "Spice Berry"] },
Abigail: { loves: ["Amethyst", "Pumpkin", "Spicy Eel"], avoid: ["Clay", "Holly"] },
Sandy: { loves: ["Crocus", "Daffodil", "Sweet Pea"], avoid: ["Holly"] },
Marnie: { loves: ["Diamond", "Pink Cake", "Pumpkin Pie"], avoid: ["Clay", "Holly"] },
Robin: { loves: ["Goat Cheese", "Peach", "Spaghetti"], avoid: ["Holly"] },
George: { loves: ["Fried Mushroom", "Leek"], avoid: ["Clay", "Holly"] },
Krobus: { loves: ["Pumpkin", "Void Egg", "Void Mayonnaise"], avoid: ["Holly", "Life Elixir"] },
Linus: { loves: ["Cactus Fruit", "Coconut", "Yam"], avoid: ["Most gems", "Treasure Chest"] },
Caroline: { loves: ["Fish Taco", "Green Tea", "Summer Spangle"], avoid: ["Quartz"] },
Sebastian: { loves: ["Frozen Tear", "Sashimi", "Void Egg"], avoid: ["Clay", "Complete Breakfast"] },
Harvey: { loves: ["Coffee", "Pickles", "Wine"], avoid: ["Coral", "Salmonberry"] },
Wizard: { loves: ["Purple Mushroom", "Solar Essence", "Void Essence"], avoid: ["Eggs", "Milk"] },
Evelyn: { loves: ["Chocolate Cake", "Fairy Rose", "Tulip"], avoid: ["Clay", "Garlic"] },
Leah: { loves: ["Goat Cheese", "Salad", "Truffle"], avoid: ["Pizza", "Void Egg"] },
Clint: { loves: ["Amethyst", "Gold Bar", "Omni Geode"], avoid: ["Holly"] }
};
const events = {
spring: {
13: ["Egg Festival", "Pelican Town, 9am–2pm. Strawberry Seeds are the prize purchase."],
@ -112,5 +151,5 @@ window.VALLEY_DATA = (() => {
]}
];
return { birthdays, events, crops, bundles, seasons: ["spring", "summer", "fall", "winter"] };
return { birthdays, giftProfiles, events, crops, bundles, seasons: ["spring", "summer", "fall", "winter"] };
})();

View file

@ -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.2.0">
<link rel="stylesheet" href="/styles.css?v=1.3.0">
<title>Valley Yearbook</title>
</head>
<body>
@ -48,6 +48,7 @@
</div>
<div class="day-grid" id="day-grid" aria-label="Day of season"></div>
<p class="calendar-key"><span aria-hidden="true">🎂</span> Villager birthday</p>
</section>
<nav class="section-nav" aria-label="Planner sections">
@ -110,13 +111,14 @@
</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>
<p>Unofficial fan-made planner for Stardew Valley 1.6. Calendar, gift, 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?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>
<script src="/data.js?v=1.3.0"></script>
<script src="/sync.js?v=1.3.0"></script>
<script src="/availability.js?v=1.3.0"></script>
<script src="/calendar.js?v=1.3.0"></script>
<script src="/app.js?v=1.3.0"></script>
</body>
</html>

View file

@ -76,10 +76,12 @@ h1 { margin-bottom: 3px; font: 700 clamp(2.8rem, 8vw, 5.8rem)/.92 Georgia, serif
.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 { position: relative; 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); }
.birthday-marker { position: absolute; top: 3px; right: 3px; font-size: clamp(.57rem, 1vw, .72rem); line-height: 1; filter: drop-shadow(0 1px 2px rgba(0,0,0,.45)); }
.calendar-key { display: flex; justify-content: flex-end; align-items: center; gap: 6px; color: var(--muted); margin: 9px 2px 0; font-size: .72rem; }
.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; }
@ -93,13 +95,18 @@ h1 { margin-bottom: 3px; font: 700 clamp(2.8rem, 8vw, 5.8rem)/.92 Georgia, serif
.today-card { min-height: 180px; padding: 22px; background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); position: relative; overflow: hidden; }
label.today-card { display: block; cursor: pointer; transition: border-color .15s ease, opacity .15s ease; }
label.today-card:hover { border-color: color-mix(in srgb, var(--season) 55%, transparent); }
.today-card > input { position: absolute; opacity: 0; pointer-events: none; }
.today-card > .checkmark { position: absolute; z-index: 2; top: 18px; right: 18px; background: var(--panel-2); }
.today-card.complete { opacity: .58; }
.today-card.complete h3 { text-decoration: line-through; }
.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; }
.gift-guide { position: relative; z-index: 1; display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 17px; padding-top: 15px; border-top: 1px solid var(--line); }
.gift-guide strong { display: flex; align-items: center; gap: 5px; color: var(--ink); font-size: .74rem; }
.gift-guide > div:first-child strong span { color: #ef8b9a; }
.gift-guide > div:last-child strong span { color: var(--danger); }
.gift-guide ul { list-style: none; margin: 6px 0 0; padding: 0; color: var(--muted); font-size: .72rem; line-height: 1.45; }
.gift-guide li + li { margin-top: 2px; }
.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; }
@ -107,11 +114,13 @@ label.today-card:hover { border-color: color-mix(in srgb, var(--season) 55%, tra
.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 { position: relative; 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; }
.today-card > input, .crop-row input, .bundle-item input { position: absolute; inset: 0; z-index: 3; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; }
.today-card > input:disabled, .crop-row input:disabled, .bundle-item input:disabled { cursor: wait; }
.checkmark { width: 22px; height: 22px; border: 1.5px solid #61746a; border-radius: 7px; display: grid; place-items: center; pointer-events: none; transition: .15s ease; }
.today-card > input:focus-visible + .checkmark, .crop-row input:focus-visible + .checkmark, .bundle-item input:focus-visible + .checkmark { outline: 3px solid var(--accent); outline-offset: 3px; }
.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; }
@ -150,7 +159,7 @@ 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); border-left: 3px solid transparent; cursor: pointer; }
.bundle-item { position: relative; 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; }