Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,34 @@ 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 |
| Featured attack | Pulled from "gets"/"learns" phrasing in the details |

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 <kbd>Enter</kbd> work, as does the mouse — and choosing a name fills in CP and shiny status.
Expand Down
46 changes: 41 additions & 5 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
45 changes: 33 additions & 12 deletions main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -330,9 +331,17 @@ document.addEventListener('DOMContentLoaded', () => {

// ---------------------------------------------------------- rendering ----

function descriptionCard({ imageUrl, name, text }) {
const image = imageUrl
? `<img src="${escapeHTML(imageUrl)}" alt="${escapeHTML(name)}" class="pokemon-image">`
/**
* `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
? `<div class="sprites sprites--${urls.length}">` +
urls.map(url => `<img src="${escapeHTML(url)}" alt="${escapeHTML(name)}" loading="lazy">`).join('') +
`</div>`
: '<p class="no-image">No image</p>';

return `<div class="description">
Expand Down Expand Up @@ -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
Expand Down
71 changes: 62 additions & 9 deletions parse.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,21 +95,55 @@ 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,
apr: 4, april: 4, may: 5, jun: 6, june: 6, jul: 7, july: 7,
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")}`;
}

Expand Down Expand Up @@ -272,30 +306,46 @@ 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) {
const header = bonuses.length > 1 ? constants.bonusHeaderMultiple : constants.bonusHeaderSingle;
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}`;
}

Expand Down Expand Up @@ -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"),
Expand Down
Loading
Loading