From 9ea07c6c8d7841bf5066cb1fce04696d89a8fdba Mon Sep 17 00:00:00 2001 From: 00xJS <56056764+00xJS@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:30:40 -0700 Subject: [PATCH] Support multi-Pokemon and unannounced events, honour written years Three problems surfaced by real schedules that the bulk importer could not express. A written year was ignored and then overridden: "July 4, 2026" pasted in August 2026 silently became 2027-07-04. An explicit year now always wins, so a past date is reported as past instead of quietly moving a year. A year is still implied when none is written, rolling forward only if that month has already been and gone. An unannounced Raid Hour rendered "Raid Hour Raid Hour" with an empty "CP - / WB - " line, both of which shipped into public posts. An event with no Pokemon now names just the event type, and the CP line appears only once there is a CP to show. Events regularly feature several Pokemon -- "Articuno, Zapdos & Moltres Raid Hour", "Magby and Smoochum Hatch Day". Names listed with commas, &, and or or are now split for CP and artwork lookups while the title keeps its original wording, so a themed name that is not really a list ("Fire and Ice Hatch Day") still reads correctly and simply resolves nothing. Each featured Pokemon gets its own labelled CP line; a single one keeps the existing unlabelled format. Artwork stacks into one square tile, three arranging as a pyramid. Co-Authored-By: Claude Opus 5 --- README.md | 23 ++++++++++- index.html | 46 ++++++++++++++++++--- main.js | 45 +++++++++++++++------ parse.js | 71 ++++++++++++++++++++++++++++----- test/parse.test.js | 99 ++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 256 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index dc2bde7..6d1dbf1 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,8 @@ From each row the tool works out: | Field | How | | --- | --- | | Event type | Matched against the known types in the event name | -| Pokémon | The part of the name that isn't the event type (hyphenated names like `Ho-Oh` and `Porygon-Z` are handled) | -| Date | Month + day; the year rolls forward if the month has already passed | +| Pokémon | The part of the name that isn't the event type. Hyphenated names like `Ho-Oh` and `Porygon-Z` are handled, and several can be listed — see below | +| Date | Month + day. **A written year is used as-is.** Without one, the current year is assumed, rolling forward if that month has already been and gone | | Bonuses | Keyword-matched from the details column, capped at the event type's limit | | Shiny | Looked up against the released-shiny list, falling back to whether "shiny" appears in the details | | Catch CP | Curated table first, then computed from base stats | @@ -37,6 +37,25 @@ From each row the tool works out: Rows it can't read are reported inline as a **Skipped** notice and don't stop the rest of the batch. +**Put the Pokémon in the Event Name**, not the Details column — `Groudon Raid Hour`, not `Raid Hour` with "Groudon" in Details. + +**Several featured Pokémon** can be listed with commas, `&`, `and` or `or`. Each gets its own labelled CP line, and their artwork stacks into one square tile (three arrange as a pyramid): + +``` +| Articuno, Zapdos & Moltres Raid Hour | five-star raids | September 2 | +``` + +``` +Articuno, Zapdos & Moltres Raid Hour +🎈 Join us at Sundance Park on September 2nd for the Articuno, Zapdos & Moltres Raid Hour from 6-7PM 💃☀️🕺 + +💯 Articuno - 1743 / WB - 2179 +💯 Zapdos - 2015 / WB - 2519 +💯 Moltres - 1980 / WB - 2475 +``` + +**Events with nothing announced yet** work too — a bare `Raid Hour` renders as just "Raid Hour" with no CP line, rather than inventing a name or leaving an empty one. + ### Manual builder Click an event type, choose how many descriptions you need (up to 20), and fill in the fields. The Pokémon field autocompletes — arrow keys and Enter work, as does the mouse — and choosing a name fills in CP and shiny status. diff --git a/index.html b/index.html index 5bd3de7..892e5e0 100644 --- a/index.html +++ b/index.html @@ -603,17 +603,45 @@ height: 150px; } - .pokemon-image { + /* One square tile however many Pokémon are featured. The light backing + keeps dark line art legible on the navy card. */ + .sprites { flex: 0 0 auto; - width: 96px; - height: 96px; - object-fit: contain; + display: grid; + place-items: center; + gap: 2px; + width: 108px; + height: 108px; padding: 6px; background: var(--sprite-bg); border: 1px solid var(--border); border-radius: var(--radius-control); } + .sprites img { + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + object-fit: contain; + } + + .sprites--2 { + grid-template-columns: 1fr 1fr; + } + + /* Three stack as a pyramid: one centred on top, two beneath. */ + .sprites--3, + .sprites--4 { + grid-template-columns: 1fr 1fr; + grid-template-rows: 1fr 1fr; + } + + .sprites--3 img:first-child { + grid-column: 1 / -1; + width: 50%; + } + .copy-feedback { font-family: var(--font-mono); font-size: 11px; @@ -663,11 +691,19 @@ color: var(--down); } + /* Same footprint as a sprite tile, so cards line up whether or not + artwork resolved. */ .no-image { flex: 0 0 auto; - width: 96px; + display: grid; + place-items: center; + width: 108px; + height: 108px; margin: 0; padding: 6px; + background: var(--bg-raised); + border: 1px dashed var(--border); + border-radius: var(--radius-control); font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.08em; diff --git a/main.js b/main.js index 3dd6d86..4e0f511 100644 --- a/main.js +++ b/main.js @@ -12,6 +12,7 @@ import { const STORAGE_KEY = 'sundance-dgen:v1'; const MAX_DESCRIPTIONS = 20; const MAX_SUGGESTIONS = 5; +const MAX_SPRITES = 4; // sprites stay legible in one square tile up to four document.addEventListener('DOMContentLoaded', () => { const eventForm = document.getElementById('event-form'); @@ -330,9 +331,17 @@ document.addEventListener('DOMContentLoaded', () => { // ---------------------------------------------------------- rendering ---- - function descriptionCard({ imageUrl, name, text }) { - const image = imageUrl - ? `${escapeHTML(name)}` + /** + * `imageUrls` may hold several sprites — a multi-Pokémon event stacks them + * into one square tile (three arrange as a pyramid) so the card keeps its + * shape however many are featured. + */ + function descriptionCard({ imageUrls = [], imageUrl, name, text }) { + const urls = (imageUrl ? [imageUrl] : imageUrls).slice(0, MAX_SPRITES); + const image = urls.length + ? `
` + + urls.map(url => `${escapeHTML(name)}`).join('') + + `
` : '

No image

'; return `
@@ -444,25 +453,37 @@ document.addEventListener('DOMContentLoaded', () => { if (skipped.length) showMessages(bulkMessages, skipped.join('')); if (!usable.length) return; - const [images, cps, shinyFlags] = await Promise.all([ - Promise.all(usable.map(row => row.pokemon ? fetchPokemonImage(row.pokemon) : null)), - Promise.all(usable.map(row => row.needsCP ? getCatchCP(row.pokemon) : null)), - Promise.all(usable.map(row => row.needsShinyCheck ? isShinyReleased(row.pokemon) : null)) + // An event can feature several Pokémon, so CP and artwork resolve per + // name. Anything that doesn't resolve is simply dropped. + const [imageSets, cpSets, shinyFlags] = await Promise.all([ + Promise.all(usable.map(row => + Promise.all(row.pokemonNames.slice(0, MAX_SPRITES).map(fetchPokemonImage)) + .then(urls => urls.filter(Boolean)))), + Promise.all(usable.map(row => row.needsCP + ? Promise.all(row.pokemonNames.map(name => + getCatchCP(name).then(cp => cp && { name, ...cp }))) + .then(list => list.filter(Boolean)) + : [])), + Promise.all(usable.map(row => row.needsShinyCheck ? isShinyReleased(row.pokemonNames[0]) : null)) ]); renderOutput(usable.map((row, i) => { - const cp = cps[i]; + const cpList = cpSets[i]; + const single = cpList.length === 1 ? cpList[0] : null; return descriptionCard({ - imageUrl: images[i], + imageUrls: imageSets[i], name: row.displayName, text: renderDescription({ eventType: row.eventType, - pokemon: row.displayName, + // row.pokemon, not displayName — displayName falls back to the + // raw event name, which would render "Raid Hour Raid Hour" + pokemon: row.pokemon, location: custom ?? undefined, formattedDate: row.formattedDate, bonuses: row.bonuses, - hundo: cp?.hundo ?? '', - whundo: cp?.whundo ?? '', + hundo: single?.hundo ?? '', + whundo: single?.whundo ?? '', + cpList, attack: row.attack, // The released-shiny list beats keyword-spotting; fall back if it failed. shinyAvailable: shinyFlags[i] !== null ? shinyFlags[i] : row.shinyFromText diff --git a/parse.js b/parse.js index 17a387e..c3a1dd7 100644 --- a/parse.js +++ b/parse.js @@ -95,7 +95,33 @@ export function extractPokemonFromEvent(name) { return ""; } -/** "August 15" -> "2026-08-15", rolling into next year once the month has passed. */ +/** + * Split a featured-Pokémon string into individual names. + * + * Events regularly feature several: "Articuno, Zapdos & Moltres Raid Hour", + * "Magby and Smoochum Hatch Day". Used only for CP and artwork lookups — the + * title keeps the original wording, so a themed name that isn't really a list + * ("Fire and Ice Hatch Day") still reads correctly, it just resolves nothing. + */ +export function splitPokemonNames(text) { + if (!text) return []; + return String(text) + .split(/\s*(?:,|&|\band\b|\bor\b)\s*/i) + .map(part => part.trim()) + .filter(Boolean); +} + +/** + * "August 15" -> "2026-08-15". + * + * An explicit year in the text always wins: "July 4, 2026" means 2026, even + * when that date has already passed (the caller then reports it as past, which + * is honest — silently moving it to 2027 was not). + * + * Only when no year is written is one implied: the current year, rolling + * forward if that month has already been and gone, so a January schedule + * pasted in December lands next January rather than being rejected. + */ export function parseDateString(dateStr, now = new Date()) { const monthMap = { jan: 1, january: 1, feb: 2, february: 2, mar: 3, march: 3, @@ -103,13 +129,21 @@ export function parseDateString(dateStr, now = new Date()) { aug: 8, august: 8, sep: 9, september: 9, oct: 10, october: 10, nov: 11, november: 11, dec: 12, december: 12 }; - const match = String(dateStr).match(/([A-Za-z]+)\s+(\d+)/); + const text = String(dateStr); + const match = text.match(/([A-Za-z]+)\s+(\d+)/); if (!match) return null; const month = monthMap[match[1].toLowerCase()]; const day = parseInt(match[2], 10); if (!month || !day) return null; - let year = now.getFullYear(); - if (month < now.getMonth() + 1) year++; + + // A 4-digit number anywhere in the cell is the year. Guard against reading + // the day itself by requiring 4 digits — "July 4" has no year, "July 4-6, + // 2026" does. Ranges collapse to the first day. + const explicitYear = text.match(/\b(20\d{2})\b/); + const year = explicitYear + ? parseInt(explicitYear[1], 10) + : now.getFullYear() + (month < now.getMonth() + 1 ? 1 : 0); + return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`; } @@ -272,12 +306,17 @@ export function renderDescription({ bonuses = [], hundo = "", whundo = "", + cpList = [], attack = "", shinyAvailable = false }) { const config = eventConfig[eventType]; if (!config) throw new Error(`Unknown event type: ${eventType}`); + // Some events have no Pokémon to name — an unannounced Raid Hour is just + // "Raid Hour", not "Raid Hour Raid Hour". + const title = pokemon ? `${pokemon} ${eventType}` : eventType; + let body = `from ${config.time} ${constants.eventEmojis}\n\n`; if (bonuses.length) { @@ -285,17 +324,28 @@ export function renderDescription({ body += `${header}\n${bonuses.map(b => `- ${b}`).join("\n")}\n\n`; } if (config.specialFields.includes("hundo")) { - body += config.specialFields.includes("whundo") - ? `💯 - ${hundo} / WB - ${whundo}\n\n` - : `💯 - ${hundo}\n\n`; + const withWB = config.specialFields.includes("whundo"); + if (cpList.length > 1) { + // Several featured Pokémon — label each line so a reader can tell + // which CP belongs to which boss. + body += cpList + .map(cp => withWB + ? `💯 ${cp.name} - ${cp.hundo} / WB - ${cp.whundo}` + : `💯 ${cp.name} - ${cp.hundo}`) + .join("\n") + "\n\n"; + } else if (String(hundo).trim()) { + // Only print the CP line once there is a CP to print. An empty + // "💯 - / WB - " is worse than no line at all in a public post. + body += withWB ? `💯 - ${hundo} / WB - ${whundo}\n\n` : `💯 - ${hundo}\n\n`; + } } if (config.specialFields.includes("attack") && attack) { body += `Evolve for featured attack: ${attack}\n\n`; } const shinyText = shinyAvailable ? constants.shinyText : ""; - return `${pokemon} ${eventType}\n` + - `🎈 Join us at ${location} on ${formattedDate} for the ${pokemon} ${eventType} ${body}` + + return `${title}\n` + + `🎈 Join us at ${location} on ${formattedDate} for the ${title} ${body}` + `${shinyText}${constants.checkInText}`; } @@ -324,6 +374,9 @@ export function parseEventRow(event, now = new Date()) { formattedDate, shinyFromText: isShinyInDetails(event.details), bonuses: extractBonusesFromDetails(eventType, event.details), + // Individual names for CP and artwork lookups; `pokemon` stays verbatim + // for the title. + pokemonNames: splitPokemonNames(pokemon), // CP is resolved by the caller (it needs the data layer); this only says // whether this event type shows a CP line at all. needsCP: !!pokemon && config.specialFields.includes("hundo"), diff --git a/test/parse.test.js b/test/parse.test.js index 91a90bf..75bc258 100644 --- a/test/parse.test.js +++ b/test/parse.test.js @@ -13,6 +13,7 @@ import { extractAttackFromDetails, isShinyInDetails, lookupCPOverride, + splitPokemonNames, calcCatchCP, indexBaseStats, pokeApiSlug, @@ -205,6 +206,66 @@ describe("renderDescription", () => { assert.match(out, /Join us at Sundance Park/); }); + // Regression: an unannounced Raid Hour rendered "Raid Hour Raid Hour" with + // an empty "💯 - / WB - " line, both of which shipped into public posts. + test("an event with no Pokémon names just the event type", () => { + const out = renderDescription({ + eventType: "Raid Hour", + pokemon: "", + formattedDate: "August 12th" + }); + assert.equal(out.split("\n")[0], "Raid Hour"); + assert.match(out, /for the Raid Hour from 6-7PM/); + assert.doesNotMatch(out, /Raid Hour Raid Hour/); + }); + + test("the CP line is omitted when there is no CP to show", () => { + const blank = renderDescription({ eventType: "Raid Hour", pokemon: "", formattedDate: "August 12th" }); + assert.doesNotMatch(blank, /💯/, "no empty CP line"); + + const known = renderDescription({ + eventType: "Raid Hour", pokemon: "Groudon", formattedDate: "August 12th", + hundo: 2351, whundo: 2939 + }); + assert.match(known, /💯 - 2351 \/ WB - 2939/); + }); + + test("labels one CP line per Pokémon when several are featured", () => { + const out = renderDescription({ + eventType: "Raid Hour", + pokemon: "Articuno, Zapdos & Moltres", + formattedDate: "July 1st", + cpList: [ + { name: "Articuno", hundo: 1743, whundo: 2179 }, + { name: "Zapdos", hundo: 2015, whundo: 2519 }, + { name: "Moltres", hundo: 1980, whundo: 2475 } + ] + }); + assert.equal(out.split("\n")[0], "Articuno, Zapdos & Moltres Raid Hour"); + assert.match(out, /💯 Articuno - 1743 \/ WB - 2179\n💯 Zapdos - 2015 \/ WB - 2519\n💯 Moltres - 1980 \/ WB - 2475/); + }); + + test("a single Pokémon keeps the unlabelled CP line", () => { + const out = renderDescription({ + eventType: "Raid Hour", pokemon: "Groudon", formattedDate: "August 12th", + hundo: 2351, whundo: 2939, cpList: [{ name: "Groudon", hundo: 2351, whundo: 2939 }] + }); + assert.match(out, /💯 - 2351 \/ WB - 2939/); + assert.doesNotMatch(out, /💯 Groudon/); + }); + + test("Max Battles labels without a weather-boosted value", () => { + const out = renderDescription({ + eventType: "Max Battles", pokemon: "Rillaboom & Cinderace", formattedDate: "August 5th", + cpList: [ + { name: "Rillaboom", hundo: 1500, whundo: 1875 }, + { name: "Cinderace", hundo: 1600, whundo: 2000 } + ] + }); + assert.match(out, /💯 Rillaboom - 1500\n💯 Cinderace - 1600/); + assert.doesNotMatch(out, /WB -/); + }); + test("throws on an unknown event type rather than emitting nonsense", () => { assert.throws(() => renderDescription({ eventType: "Nope", pokemon: "X", formattedDate: "y" }), /Unknown event type/); }); @@ -236,6 +297,25 @@ not a table row }); }); +describe("splitPokemonNames", () => { + test("splits the ways schedules actually write lists", () => { + assert.deepEqual(splitPokemonNames("Articuno, Zapdos & Moltres"), ["Articuno", "Zapdos", "Moltres"]); + assert.deepEqual(splitPokemonNames("Magby and Smoochum"), ["Magby", "Smoochum"]); + assert.deepEqual(splitPokemonNames("Uxie, Mesprit or Azelf"), ["Uxie", "Mesprit", "Azelf"]); + }); + + test("leaves a single name alone, including hyphenated ones", () => { + assert.deepEqual(splitPokemonNames("Ho-Oh"), ["Ho-Oh"]); + assert.deepEqual(splitPokemonNames("Mr. Mime"), ["Mr. Mime"]); + // "or" inside a word must not split it + assert.deepEqual(splitPokemonNames("Porygon"), ["Porygon"]); + }); + + test("returns empty for an empty name", () => { + assert.deepEqual(splitPokemonNames(""), []); + }); +}); + describe("parseDateString", () => { test("resolves a month/day against the current year", () => { assert.equal(parseDateString("August 15", NOW), "2026-08-15"); @@ -250,6 +330,25 @@ describe("parseDateString", () => { assert.equal(parseDateString("sometime soon", NOW), null); assert.equal(parseDateString("Smarch 4", NOW), null); }); + + // Regression: the written year was ignored entirely, so "July 4, 2026" read + // in August 2026 silently became 2027 instead of being reported as past. + test("an explicit year always wins, even when that date has passed", () => { + assert.equal(parseDateString("July 4, 2026", NOW), "2026-07-04"); + assert.equal(parseDateString("August 12, 2026", NOW), "2026-08-12"); + assert.equal(parseDateString("January 10, 2027", NOW), "2027-01-10"); + }); + + test("a date range collapses to its first day", () => { + assert.equal(parseDateString("July 4-6, 2026", NOW), "2026-07-04"); + }); + + // The year is detected by requiring four digits, so a day number can never + // be mistaken for one. + test("a bare day number is not read as a year", () => { + assert.equal(parseDateString("July 4", NOW), "2027-07-04"); // implied, rolled forward + assert.equal(parseDateString("September 5", NOW), "2026-09-05"); // implied, current year + }); }); describe("formatDate", () => {