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
- ? `
`
+ /**
+ * `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 => `
})
`).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", () => {