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", () => {