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

@ -4,7 +4,7 @@ A small, shared tracker for the first two years of Stardew Valley. It combines:
- the standard Community Center bundles; - the standard Community Center bundles;
- crop growth times and last safe planting dates; - crop growth times and last safe planting dates;
- birthdays, festivals, seasonal events, and weekly reminders; and - birthday previews and gift tips, festivals, seasonal events, and weekly reminders; and
- one server-side checklist that stays in sync across phones and computers. - one server-side checklist that stays in sync across phones and computers.
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. 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.
@ -35,6 +35,6 @@ When proxying through Nginx, protect the complete origin with Authelia and pass
## Data scope ## Data scope
The planner targets Stardew Valley 1.6 and standard bundles, not remixed bundles. Calendar and bundle data is based on the [Stardew Valley Wiki](https://stardewvalleywiki.com/Calendar). The planner targets Stardew Valley 1.6 and standard bundles, not remixed bundles. Calendar and bundle data is based on the [Stardew Valley Wiki calendar](https://stardewvalleywiki.com/Calendar); birthday gift suggestions use its [gift reference](https://stardewvalleywiki.com/List_of_All_Gifts).
Stardew Valley is © ConcernedApe. This is an unofficial fan-made utility and includes no game art or assets. Stardew Valley is © ConcernedApe. This is an unofficial fan-made utility and includes no game art or assets.

View file

@ -32,8 +32,11 @@
node --check static/data.js node --check static/data.js
node --check static/sync.js node --check static/sync.js
node --check static/availability.js node --check static/availability.js
node --check static/calendar.js
node tests/test_sync.js node tests/test_sync.js
node tests/test_availability.js node tests/test_availability.js
node tests/test_calendar.js
node tests/test_data.js
touch $out touch $out
''; '';
}); });

View file

@ -2,7 +2,7 @@
stdenvNoCC.mkDerivation { stdenvNoCC.mkDerivation {
pname = "stardew-tracker"; pname = "stardew-tracker";
version = "1.2.0"; version = "1.3.0";
src = ./.; src = ./.;
nativeBuildInputs = [ makeWrapper ]; nativeBuildInputs = [ makeWrapper ];

View file

@ -2,6 +2,7 @@
const data = window.VALLEY_DATA; const data = window.VALLEY_DATA;
const sync = window.ValleySync; const sync = window.ValleySync;
const availability = window.ValleyAvailability; const availability = window.ValleyAvailability;
const calendar = window.ValleyCalendar;
const weekdays = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]; const weekdays = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
const seasonNames = { spring: "Spring", summer: "Summer", fall: "Fall", winter: "Winter" }; const seasonNames = { spring: "Spring", summer: "Summer", fall: "Fall", winter: "Winter" };
const state = { const state = {
@ -59,15 +60,19 @@
$$("[data-season]").forEach(button => button.setAttribute("aria-selected", String(button.dataset.season === state.season))); $$("[data-season]").forEach(button => button.setAttribute("aria-selected", String(button.dataset.season === state.season)));
$("#day-grid").innerHTML = Array.from({ length: 28 }, (_, index) => { $("#day-grid").innerHTML = Array.from({ length: 28 }, (_, index) => {
const day = index + 1; 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(""); }).join("");
} }
function birthdayOn(day) {
return calendar.birthdayOn(data.birthdays, state.season, day, state.year);
}
function currentBirthday() { function currentBirthday() {
const entry = data.birthdays[state.season][state.day]; return birthdayOn(state.day);
if (!entry) return null;
if (entry[1] && entry[1] !== state.year) return null;
return entry[0];
} }
function currentEvent() { function currentEvent() {
@ -96,9 +101,19 @@
return `daily-${state.year}-${state.season}-${state.day}-${kind}-${slug}`; 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 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 return itemId
? `<label class="today-card ${kind} ${checked ? "complete" : ""}">${content}</label>` ? `<label class="today-card ${kind} ${checked ? "complete" : ""}">${content}</label>`
: `<article class="today-card ${kind}">${content}</article>`; : `<article class="today-card ${kind}">${content}</article>`;
@ -108,7 +123,7 @@
const cards = []; const cards = [];
const birthday = currentBirthday(); const birthday = currentBirthday();
const event = currentEvent(); 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]))); 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)))); 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)))); 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 overdue = state.day > crop.last && !checked;
const today = state.day === crop.last && !checked; const today = state.day === crop.last && !checked;
return `<label class="crop-row ${checked ? "complete" : ""} ${overdue ? "overdue" : ""}"> 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="checkmark" aria-hidden="true"></span>
<span class="crop-name"><strong>${escapeHtml(crop.name)}</strong><small>${escapeHtml(crop.purpose)}</small></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="grow-time">${crop.grow} days<small>to grow</small></span>
@ -203,7 +218,7 @@
const checked = Boolean(state.checks[entry.id]); const checked = Boolean(state.checks[entry.id]);
const status = entryStatus(entry, group); 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(" "); 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> }).join("")}</div>
</article>`; </article>`;
}).filter(Boolean).join(""); }).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 = (() => { window.VALLEY_DATA = (() => {
const birthdays = { 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"] }, 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"] }, 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"] } 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 = { const events = {
spring: { spring: {
13: ["Egg Festival", "Pelican Town, 9am–2pm. Strawberry Seeds are the prize purchase."], 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="theme-color" content="#132720">
<meta name="description" content="A shared two-year Stardew Valley Community Center planner."> <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="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> <title>Valley Yearbook</title>
</head> </head>
<body> <body>
@ -48,6 +48,7 @@
</div> </div>
<div class="day-grid" id="day-grid" aria-label="Day of season"></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> </section>
<nav class="section-nav" aria-label="Planner sections"> <nav class="section-nav" aria-label="Planner sections">
@ -110,13 +111,14 @@
</main> </main>
<footer> <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> </footer>
</div> </div>
<div id="toast" class="toast" role="alert" aria-live="assertive"></div> <div id="toast" class="toast" role="alert" aria-live="assertive"></div>
<script src="/data.js?v=1.2.0"></script> <script src="/data.js?v=1.3.0"></script>
<script src="/sync.js?v=1.2.0"></script> <script src="/sync.js?v=1.3.0"></script>
<script src="/availability.js?v=1.2.0"></script> <script src="/availability.js?v=1.3.0"></script>
<script src="/app.js?v=1.2.0"></script> <script src="/calendar.js?v=1.3.0"></script>
<script src="/app.js?v=1.3.0"></script>
</body> </body>
</html> </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 span { margin-right: 7px; }
.season-tabs button[aria-selected="true"] { color: var(--ink); border-color: var(--season); background: var(--season-soft); } .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 { 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 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: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); } .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 { 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 { 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; } .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 { 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); } 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 > .checkmark { position: absolute; z-index: 2; top: 18px; right: 18px; background: var(--panel-2); }
.today-card.complete { opacity: .58; } .today-card.complete { opacity: .58; }
.today-card.complete h3 { text-decoration: line-through; } .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::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 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; } .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 { 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; } .card-label span { width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
.today-card.birthday { --season: #f1c96d; } .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; } .today-card.event { --season: #86bcd8; }
.crop-list { border: 1px solid var(--line); background: var(--panel); border-radius: var(--radius); overflow: hidden; } .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:last-child { border: 0; }
.crop-row:hover { background: rgba(255,255,255,.025); } .crop-row:hover { background: rgba(255,255,255,.025); }
.crop-row input, .bundle-item input { position: absolute; opacity: 0; pointer-events: none; } .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; }
.checkmark { width: 22px; height: 22px; border: 1.5px solid #61746a; border-radius: 7px; display: grid; place-items: center; transition: .15s ease; } .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 { background: var(--season); border-color: var(--season); }
.complete .checkmark::after { content: "✓"; color: var(--deep); font-weight: 950; font-size: .86rem; } .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 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 header > span { color: var(--muted); font-size: .8rem; font-weight: 800; }
.bundle-card.complete > header { background: var(--season-soft); } .bundle-card.complete > header { background: var(--season-soft); }
.bundle-card.complete header > span { color: var(--season); } .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:last-child { border-bottom: 0; }
.bundle-item:hover { background: rgba(255,255,255,.025); } .bundle-item:hover { background: rgba(255,255,255,.025); }
.bundle-item.complete > span:last-child { opacity: .5; } .bundle-item.complete > span:last-child { opacity: .5; }

View file

@ -47,5 +47,13 @@ class StoreTests(unittest.TestCase):
self.assertTrue(all(snapshot["checks"].values())) self.assertTrue(all(snapshot["checks"].values()))
class StaticInteractionTests(unittest.TestCase):
def test_native_checkboxes_cover_each_tickable_row(self):
styles = (Path(__file__).parent.parent / "static" / "styles.css").read_text()
self.assertIn(".today-card > input, .crop-row input, .bundle-item input", styles)
self.assertIn("inset: 0", styles)
self.assertNotIn(".crop-row input, .bundle-item input { position: absolute; opacity: 0; pointer-events: none; }", styles)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

14
tests/test_calendar.js Normal file
View file

@ -0,0 +1,14 @@
const assert = require("node:assert/strict");
const calendar = require("../static/calendar.js");
const birthdays = {
spring: { 4: ["Kent", 2], 7: ["Lewis"] }
};
assert.equal(calendar.birthdayOn(birthdays, "spring", 4, 1), null);
assert.equal(calendar.birthdayOn(birthdays, "spring", 4, 2), "Kent");
assert.equal(calendar.birthdayOn(birthdays, "spring", 7, 1), "Lewis");
assert.equal(calendar.birthdayOn(birthdays, "spring", 7, 2), "Lewis");
assert.equal(calendar.birthdayOn(birthdays, "spring", 8, 1), null);
console.log("calendar tests passed");

18
tests/test_data.js Normal file
View file

@ -0,0 +1,18 @@
const assert = require("node:assert/strict");
global.window = {};
require("../static/data.js");
const data = global.window.VALLEY_DATA;
const birthdayNames = Object.values(data.birthdays)
.flatMap(days => Object.values(days))
.map(([name]) => name);
for (const name of birthdayNames) {
const profile = data.giftProfiles[name];
assert.ok(profile, `${name} needs a gift profile`);
assert.ok(profile.loves.length > 0, `${name} needs a loved gift`);
assert.ok(profile.avoid.length > 0, `${name} needs a gift to avoid`);
}
console.log("birthday gift data tests passed");