From 67951e81b42a949b3835c4e5cfaab0f472cb616c Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Mon, 17 Aug 2026 17:20:54 +0200 Subject: [PATCH] feat(nikki): track Infinity Nikki MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the game and its Game8 events page. One `SOURCES` entry against the existing game8 parser, plus one date shape that page needs: `parseLabelledStartEnd` reads a duration cell holding `Start: ` and `End: ` split by a `
`, which a tag-stripping reader flattens into one run of text. Four of the seven events say `End: Permanent`. That is the source telling us there is no deadline, so they publish with `endsAt: null` and `endPrecision: "unknown"` rather than a plausible date. The labelled cell is also structure end to end, so it never becomes a summary — stripping the leading half would leave "End: Permanent" standing where a description belongs. Verified by re-extracting the page with a throwaway script independent of the parser: 7 rows in, 7 events out, every date matching. Note the source itself is stale — its "Current Events" table still lists January-May 2025 and "Upcoming Events" says there are none. The adapter is right; the page looks abandoned, and Nikki coverage worth relying on needs a second source at a higher priority. Co-Authored-By: Claude Opus 5 (1M context) --- .../game8-events-2026-08-17.expected.json | 149 +++ fixtures/nikki/game8-events-2026-08-17.html | 1119 +++++++++++++++++ src/ingest/adapters/index.ts | 6 + src/ingest/dates.ts | 28 + src/ingest/parsers/game8.ts | 7 + src/shared/games.ts | 1 + src/shared/schema.ts | 1 + test/adapters/game8.test.ts | 32 + test/dates.test.ts | 37 + 9 files changed, 1380 insertions(+) create mode 100644 fixtures/nikki/game8-events-2026-08-17.expected.json create mode 100644 fixtures/nikki/game8-events-2026-08-17.html diff --git a/fixtures/nikki/game8-events-2026-08-17.expected.json b/fixtures/nikki/game8-events-2026-08-17.expected.json new file mode 100644 index 0000000..5ef7551 --- /dev/null +++ b/fixtures/nikki/game8-events-2026-08-17.expected.json @@ -0,0 +1,149 @@ +[ + { + "id": "nikki:explosive-evasion:2025-01-24", + "game": "nikki", + "title": "Explosive Evasion", + "type": "other", + "summary": null, + "startsAt": "2025-01-24T00:00:00.000Z", + "startPrecision": "day", + "endsAt": null, + "endPrecision": "unknown", + "regionScoped": false, + "regionEnds": null, + "sourceUrl": "https://game8.co/games/Infinity-Nikki/archives/487445", + "sourceId": "nikki-game8-events", + "status": "published", + "confidence": 0.75, + "extractionMethod": "parser", + "version": 1, + "firstSeenAt": "2026-08-14T00:00:00.000Z", + "updatedAt": "2026-08-14T00:00:00.000Z" + }, + { + "id": "nikki:fireworks-fiesta:2025-01-24", + "game": "nikki", + "title": "Fireworks Fiesta", + "type": "other", + "summary": null, + "startsAt": "2025-01-24T00:00:00.000Z", + "startPrecision": "day", + "endsAt": null, + "endPrecision": "unknown", + "regionScoped": false, + "regionEnds": null, + "sourceUrl": "https://game8.co/games/Infinity-Nikki/archives/487445", + "sourceId": "nikki-game8-events", + "status": "published", + "confidence": 0.75, + "extractionMethod": "parser", + "version": 1, + "firstSeenAt": "2026-08-14T00:00:00.000Z", + "updatedAt": "2026-08-14T00:00:00.000Z" + }, + { + "id": "nikki:fireworks-styling-challenges:2025-01-24", + "game": "nikki", + "title": "Fireworks Styling Challenges", + "type": "other", + "summary": null, + "startsAt": "2025-01-24T00:00:00.000Z", + "startPrecision": "day", + "endsAt": null, + "endPrecision": "unknown", + "regionScoped": false, + "regionEnds": null, + "sourceUrl": "https://game8.co/games/Infinity-Nikki/archives/487445", + "sourceId": "nikki-game8-events", + "status": "published", + "confidence": 0.75, + "extractionMethod": "parser", + "version": 1, + "firstSeenAt": "2026-08-14T00:00:00.000Z", + "updatedAt": "2026-08-14T00:00:00.000Z" + }, + { + "id": "nikki:balloon-hopper:2025-01-31", + "game": "nikki", + "title": "Balloon Hopper", + "type": "other", + "summary": null, + "startsAt": "2025-01-31T00:00:00.000Z", + "startPrecision": "day", + "endsAt": null, + "endPrecision": "unknown", + "regionScoped": false, + "regionEnds": null, + "sourceUrl": "https://game8.co/games/Infinity-Nikki/archives/487445", + "sourceId": "nikki-game8-events", + "status": "published", + "confidence": 0.75, + "extractionMethod": "parser", + "version": 1, + "firstSeenAt": "2026-08-14T00:00:00.000Z", + "updatedAt": "2026-08-14T00:00:00.000Z" + }, + { + "id": "nikki:bubble-hopper:2025-04-28", + "game": "nikki", + "title": "Bubble Hopper", + "type": "other", + "summary": null, + "startsAt": "2025-04-28T00:00:00.000Z", + "startPrecision": "day", + "endsAt": null, + "endPrecision": "unknown", + "regionScoped": false, + "regionEnds": null, + "sourceUrl": "https://game8.co/games/Infinity-Nikki/archives/487445", + "sourceId": "nikki-game8-events", + "status": "published", + "confidence": 0.75, + "extractionMethod": "parser", + "version": 1, + "firstSeenAt": "2026-08-14T00:00:00.000Z", + "updatedAt": "2026-08-14T00:00:00.000Z" + }, + { + "id": "nikki:bubble-season:2025-04-28", + "game": "nikki", + "title": "Bubble Season", + "type": "other", + "summary": null, + "startsAt": "2025-04-28T00:00:00.000Z", + "startPrecision": "day", + "endsAt": "2025-06-12T00:00:00.000Z", + "endPrecision": "day", + "regionScoped": false, + "regionEnds": null, + "sourceUrl": "https://game8.co/games/Infinity-Nikki/archives/487445", + "sourceId": "nikki-game8-events", + "status": "published", + "confidence": 0.85, + "extractionMethod": "parser", + "version": 1, + "firstSeenAt": "2026-08-14T00:00:00.000Z", + "updatedAt": "2026-08-14T00:00:00.000Z" + }, + { + "id": "nikki:cozy-caretakers-protect-the-pandas:2025-05-20", + "game": "nikki", + "title": "Cozy Caretakers: Protect the Pandas", + "type": "other", + "summary": null, + "startsAt": "2025-05-20T00:00:00.000Z", + "startPrecision": "day", + "endsAt": "2025-06-12T00:00:00.000Z", + "endPrecision": "day", + "regionScoped": false, + "regionEnds": null, + "sourceUrl": "https://game8.co/games/Infinity-Nikki/archives/487445", + "sourceId": "nikki-game8-events", + "status": "published", + "confidence": 0.85, + "extractionMethod": "parser", + "version": 1, + "firstSeenAt": "2026-08-14T00:00:00.000Z", + "updatedAt": "2026-08-14T00:00:00.000Z" + } +] diff --git a/fixtures/nikki/game8-events-2026-08-17.html b/fixtures/nikki/game8-events-2026-08-17.html new file mode 100644 index 0000000..ff614d8 --- /dev/null +++ b/fixtures/nikki/game8-events-2026-08-17.html @@ -0,0 +1,1119 @@ +List of All Events and Schedule | Infinity Nikki|Game8 + + + + + + + + +

What can you do as a free member?

Member benefits illustration

Create your free account today and unlock all our premium features and tools to enhance your gaming experience.

Member benefits illustration

Create your free account today and save articles to your watchlist and get notified when they're updated with new information.

Member benefits illustration

Create your free account today and save your favorite games for quick access later, synced across all your devices.

Member benefits illustration

By creating a Game8 account and logging in, you'll receive instant notifications when someone replies to your posts.

Comment rating feature illustration

By creating a Game8 account and logging in, you can make use of convenient features in the comments section, such as rating and sorting comments.

Premium archive feature illustration

By creating a Game8 account and logging in, you can access Premium articles that are exclusively available to members.

Map tool feature illustration

By creating a Game8 account and logging in, the items you checked on the map tool will be saved.

Site Interface

Guest
Free Member
Article Watchlist
Game Bookmarks
Cross-device Sync
Light/Dark Theme Toggle
User Profiles
Direct Feedback
Comment Rating
Comment Notification
Premium Articles

Game Tools

Guest
Free Member
Interactive Map Access
Interactive Map Pins
Interactive Map Comments
Interactive Map Pins Cross-Device
Check List
Event Choice Checker
Deck Builder Cross-Device
Message Board Notification
Message Board Cross-Device
Build Planner
Stat Calculator
Diagnostic Tool
Weapon/Armor Wishlist
Team Builder
Character Scoring
Tier List Maker

Want more information?Learn more

+
Infinity Nikki

List of All Events and Schedule

+ + +Add to Watchlist
+

Hey, Stylists! We regret to inform everyone that we will no longer be updating Infinity Nikki as of Version 1.6. The Friend Code Sharing board will still be kept open, but keep in mind that most guides past Version 1.5 are not up-to-date. Thank you for your support!

+ +

Infinity Nikki - List of All Events and Schedule

+ +

Here's a list all of Infinity Nikki's Events and Schedule!

+ +

+

List of All Events and Schedule

Current Events in Infinity Nikki

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
EventsEvent Duration
+ +Infinity Nikki - Fireworks Fiesta Event Guide Fireworks Fiesta + + + + + + + + + +Start: January 24, 2025
+End: Permanent + +
+ +Infinity Nikki - Fireworks Styling Challenges Event Guide Fireworks Styling Challenges + + + + + + + + + +Start: January 24, 2025
+End: Permanent + +
+ +Infinity Nikki - Explosive Evasion Event Guide Explosive Evasion + + + + + + + + + +Start: January 24, 2025
+End: Permanent + +
+ +Infinity Nikki - Balloon Hopper Event Guide Balloon Hopper + + + + + + + + + +Start: January 31, 2025
+End: Permanent + +
+ +Infinity Nikki - Bubble Season Event Guide Bubble Season + + + + + + + + + +Start: April 28, 2025
+End: June 12, 2025 + +
+ +Infinity Nikki - Bubble Hopper Event Guide Bubble Hopper + + + + + + + + + +Start: April 28, 2025
+End: Permanent + +
+ +Infinity Nikki - Cozy Caretakers: Protect the Pandas Event Guide Cozy Caretakers: Protect the Pandas + + + + + + + + + +Start: May 20, 2025
+End: June 12, 2025 + +
+

Upcoming Events in Infinity Nikki

+ + + + +
There are no events yet.
+

List of Past Events

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
All Infinity Nikki Past Events
+ +Infinity Nikki - Heartfelt Wish Event Guide Heartfelt Wish + + + + + +Infinity Nikki - Wishfield Journal Event Guide Wishfield Journal + + + +
+ +Infinity Nikki - Whimsical Journey Web Event Guide Whimsical Journey + + + + + +Infinity Nikki - Companion Companion's Day + + + +
+ +Infinity Nikki - Star-Kissed Wishes Event Guide Star-Kissed Wishes + + + + + +Infinity Nikki - Let Let's Touch Shooting Stars! + + + +
+ +Infinity Nikki - Take the Bait, Pink Ribbon Eel! Event Guide Take the Bait, Pink Ribbon Eel! + + + + + +Infinity Nikki - Lucky Gifts Event Guide Lucky Gifts + + + +
+ +Infinity Nikki - Into a Starry Night Event Guide Into a Starry Night + + + + + +Infinity Nikki - Wishful Wonders Event Guide Wishful Wonders + + + +
+ +Infinity Nikki - Inspiration Burst Event Guide Inspiration Burst + + + + + +Infinity Nikki - Starry Lake Serenade Event Guide Starry Lake Serenade + + + +
+ +Infinity Nikki - Strings of Time Event Guide Strings of Time + + + + + +Infinity Nikki - New Bloom Festival Event Guide New Bloom Festival + + + +
+ +Infinity Nikki - Carnival of Fireworks Event Guide Carnival of Fireworks + + + + + +Infinity Nikki - Drifting Whim Event Guide Drifting Whim + + + +
+ +Infinity Nikki - New Horizons Day Event Guide New Horizons Day + + + + + +Infinity Nikki - Whim Tangram Event Guide Whim Tangram + + + +
+ +Infinity Nikki - New Bloom Snapshot Event Guide New Bloom Snapshot + + + + + +Infinity Nikki - Dancing Fireworks Event Guide Dancing Fireworks + + + +
+ +Infinity Nikki - Bright Moments Event Guide Bright Moments + + + + + +Infinity Nikki - Alison Alison's Travel Shop + + + +
+ +Infinity Nikki - The Queen The Queen's Lament + + + + + +Infinity Nikki - Illusibloom Depths Event Guide Illusibloom Depths + + + +
+ +Infinity Nikki - Bullquet Care Day Event Guide Bullquet Care Day + + + + + +Infinity Nikki - Queen Queen's Favor + + + +
+ +Infinity Nikki - The Palace Feast Event Guide The Palace Feast + + + + + +Infinity Nikki - Wish Carnival Party Event Guide Wish Carnival Party + + + +
+ +Infinity Nikki - Wishing Dance Party Event Guide Wishing Dance Party + + + + + +Infinity Nikki - The Party The Party's On + + + +
+ +Infinity Nikki - Fields of Vernal Bloom Event Guide Fields of Vernal Bloom + + + + + +Infinity Nikki - Carnival in Frames Event Guide Carnival in Frames + + + +
+ +Infinity Nikki - Dream or Illusion? Event Guide Dream or Illusion? + + + + + +Infinity Nikki - Wishing Rhapsody Event Guide Wishing Rhapsody + + + +
+ +Infinity Nikki - Wishing Dance Moment Event Guide Wishing Dance Moment + + + + + +Infinity Nikki - Whirl of Melodies Event Guide Whirl of Melodies + + + +
+

Infinity Nikki Related Guides

+

Infinity Nikki -Infinity Nikki
Infinity Nikki Home Page

+

Guides by Category

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
All Categories
Infinity Nikki - News PartialNews and Updates +Infinity Nikki - Banner Banners
Infinity Nikki - Quest PartialQuests +Infinity Nikki - Maps and Locations Partial BannerMaps & Location
Infinity Nikki - OutfitsOutfitsInfinity Nikki - Clothing PiecesClothing Pieces
Tips and Tricks PartialTips and TricksInfinity Nikki - ItemsItems
Infinity Nikki - Hair HairInfinity Nikki - Characters Slim BannerCharacters
Infinity Nikki - Realm ChallengeRealm ChallengesInfinity Nikki - Message BoardMessage Boards
Infinity Nikki - All Events and Schedule EventsInfinity Nikki - Whim Fragrance Whim Fragrance
Infinity Nikki - Makeup MakeupInfinity Nikki - Esselings Esselings
+

All Recommended Guides

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Recommended Guides
Infinity Nikki - Redeem CodesAll Redeem Codes in January 2025Infinity Nikki - Heart of Infinity Heart of Infinity
Infinity Nikki - List of All CurrenciesList of All CurrenciesInfinity Nikki - List of All ShopsList of All Shops
Infinity Nikki - How to Farm and Spend BlingHow to Farm and Spend Bling (Money)Infinity Nikki - How to Farm DiamondsHow to Farm Diamonds
Infinity Nikki - How to Get Stellarite: Top-Up GuideHow to Get StellariteInfinity Nikki - How to Get Revelation CrystalsHow to Get Revelation Crystals
Infinity Nikki - How to Get Resonite CrystalsHow to Get Resonite CrystalsInfinity Nikki - How to Get HeartshineHow to Get Heartshine
Infinity Nikki - Styling Challenge Guide Styling Challenge GuideInfinity Nikki - Mira Crown Styling and Pinnacle Contest Guide Mira Crown Guide
Mini-Game Guides
Infinity Nikki - All Folklore Mini-Game LocationsAll Folklore Mini-Game LocationsInfinity Nikki - Crane Flight Mini-Game GuideCrane Flight Mini-Game Guide
Infinity Nikki - Marble King Mini-Game Guide.pngMarble King Mini-Game GuideInfinity Nikki - Among Pieceys Mini-Game Guide.pngAmong Pieceys Mini-Game Guide
Infinity Nikki - Wishing Orb Express Locations and Mini-Game GuideWishing Orb Express Guide
+

Author

    +

    All rights reserved

    + + +
    Guide Index
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    \ No newline at end of file diff --git a/src/ingest/adapters/index.ts b/src/ingest/adapters/index.ts index 9a42ff1..773a91c 100644 --- a/src/ingest/adapters/index.ts +++ b/src/ingest/adapters/index.ts @@ -66,6 +66,12 @@ const SOURCES: SourceSpec[] = [ url: "https://game8.co/games/Neverness-to-Everness/archives/592073", parserId: "game8", }, + { + id: "nikki-game8-events", + game: "nikki", + url: "https://game8.co/games/Infinity-Nikki/archives/487445", + parserId: "game8", + }, ]; function toAdapter(spec: SourceSpec): Adapter { diff --git a/src/ingest/dates.ts b/src/ingest/dates.ts index a009549..e17caa3 100644 --- a/src/ingest/dates.ts +++ b/src/ingest/dates.ts @@ -147,6 +147,34 @@ export function parseShortSlashRange( }; } +/** + * "Start: January 24, 2025 End: Permanent" → the start, and whatever the end + * half turns out to be. + * + * A labelled cell rather than a range: the two halves are separated by a `
    ` + * and each carries its own word. The colons are required, which is what stops + * the word "end" inside a description from splitting a cell that is really + * prose. + * + * An end half that is not a date ("Permanent", "TBD", "After maintenance") + * yields a null end rather than an invented one — the same outcome as + * `parseOpenRange`, and for the same reason. + */ +export function parseLabelledStartEnd( + input: string, +): { start: ParsedInstant; end: ParsedInstant | null } | null { + const m = /^\s*start\s*[::]\s*(.+?)(?:\s+end\s*[::]\s*(.*?))?\s*$/i.exec( + input, + ); + if (!m) return null; + + const start = parseMonthDayYear(m[1] ?? ""); + if (start === null) return null; + + const endHalf = m[2]; + return { start, end: endHalf === undefined ? null : parseMonthDayYear(endHalf) }; +} + /** * A range whose start is a real date but whose end is not: "July 10, 2026 - * Permanent", "Jul. 24, 2026 - End of 4.6", or a lone start date. diff --git a/src/ingest/parsers/game8.ts b/src/ingest/parsers/game8.ts index 9c58bb6..8b5b997 100644 --- a/src/ingest/parsers/game8.ts +++ b/src/ingest/parsers/game8.ts @@ -5,6 +5,7 @@ import { } from "../../shared/schema.ts"; import { parseFullRange, + parseLabelledStartEnd, parseMonthDayRange, parseMonthDayYear, parseOpenRange, @@ -313,6 +314,11 @@ function isRequirementOnly(text: string): boolean { /** Strip a leading label and date range, leaving any description behind it. */ function proseAfterDates(cell: string): string | null { + // A fully labelled "Start: … End: …" cell is structure end to end. Stripping + // the leading half would leave "End: Permanent" standing where a description + // belongs — a date masquerading as a blurb. + if (parseLabelledStartEnd(cell) !== null) return null; + const rest = cell .replace(/^\s*(period|duration|schedule|dates?)\s*[::]\s*/i, "") .replace(RANGE_PREFIX, "") @@ -330,6 +336,7 @@ function parseRange( parseFullRange(value) ?? parseShortSlashRange(value) ?? parseMonthDayRange(value) ?? + parseLabelledStartEnd(value) ?? parseOpenRange(value) ); } diff --git a/src/shared/games.ts b/src/shared/games.ts index 89f4b75..46eebb3 100644 --- a/src/shared/games.ts +++ b/src/shared/games.ts @@ -53,6 +53,7 @@ export const GAMES: Record = { // and `america` already resolves to -5, so Europe is the only real override. endfield: { id: "endfield", name: "Arknights: Endfield", short: "Endfield", hue: "#E8635A" , studio: "Hypergryph", dailyTasks: "Daily missions", resetOffsets: { europe: -5 } }, nte: { id: "nte", name: "Neverness to Everness", short: "NTE", hue: "#C77DFF" , studio: "Hotta Studio", dailyTasks: "Daily tasks" }, + nikki: { id: "nikki", name: "Infinity Nikki", short: "Nikki", hue: "#F27BB0" , studio: "Infold Games", dailyTasks: "Daily tasks, Vital Energy" }, }; export const GAME_LIST: GameMeta[] = Object.values(GAMES); diff --git a/src/shared/schema.ts b/src/shared/schema.ts index 42f8406..696ea81 100644 --- a/src/shared/schema.ts +++ b/src/shared/schema.ts @@ -8,6 +8,7 @@ export const GameId = z.enum([ "arknights", "endfield", "nte", // Neverness to Everness + "nikki", // Infinity Nikki ]); export type GameId = z.infer; diff --git a/test/adapters/game8.test.ts b/test/adapters/game8.test.ts index 9aa429e..f8af79f 100644 --- a/test/adapters/game8.test.ts +++ b/test/adapters/game8.test.ts @@ -27,6 +27,7 @@ const CASES: Array<{ adapter: Adapter; fixture: string }> = [ { adapter: adapter("zzz-game8-events"), fixture: "fixtures/zzz/game8-events-2026-08-14" }, { adapter: adapter("endfield-game8-events"), fixture: "fixtures/endfield/game8-events-2026-08-14" }, { adapter: adapter("endfield-wikigg-events"), fixture: "fixtures/endfield/wikigg-events-2026-08-14" }, + { adapter: adapter("nikki-game8-events"), fixture: "fixtures/nikki/game8-events-2026-08-17" }, ]; async function runAdapter(adapter: Adapter, fixture: string) { @@ -226,6 +227,37 @@ describe("endfield", () => { }); }); +describe("nikki", () => { + const fixture = "fixtures/nikki/game8-events-2026-08-17"; + + test("reads a labelled Start/End cell, and takes no end from 'Permanent'", async () => { + // The duration cell is "Start: January 24, 2025 End: Permanent" — two + // labelled halves separated by a
    , which a tag-stripping reader sees as + // one run of text. + const events = await runAdapter(adapter("nikki-game8-events"), fixture); + expect(events).toHaveLength(7); // every row of the current-events table + + const fiesta = events.find((e) => e.title === "Fireworks Fiesta"); + expect(fiesta?.startsAt).toBe("2025-01-24T00:00:00.000Z"); + // "Permanent" is not a date and does not become one. + expect(fiesta?.endsAt).toBeNull(); + expect(fiesta?.endPrecision).toBe("unknown"); + + const bubble = events.find((e) => e.title === "Bubble Season"); + expect(bubble?.startsAt).toBe("2025-04-28T00:00:00.000Z"); + expect(bubble?.endsAt).toBe("2025-06-12T00:00:00.000Z"); + }); + + test("never presents the labelled cell's own text as a summary", async () => { + // "End: Permanent" is structure, not a blurb. Leaving it in the summary + // slot would show a reader a date the parser deliberately refused to use. + const events = await runAdapter(adapter("nikki-game8-events"), fixture); + for (const e of events) { + expect(e.summary).toBeNull(); + } + }); +}); + describe("wiki.gg parser", () => { test("reads exact per-region timers", async () => { // The first source that states region-scoped ends. Asia and the Americas diff --git a/test/dates.test.ts b/test/dates.test.ts index df7e059..7cbfe0d 100644 --- a/test/dates.test.ts +++ b/test/dates.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { parseFullRange, + parseLabelledStartEnd, parseMonthDayRange, parseMonthDayYear, parseOpenRange, @@ -127,3 +128,39 @@ describe("parseOpenRange", () => { expect(parseOpenRange("Releases in Version 3.6")).toBeNull(); }); }); + +describe("parseLabelledStartEnd", () => { + test("reads a labelled cell", () => { + expect( + parseLabelledStartEnd("Start: April 28, 2025 End: June 12, 2025"), + ).toEqual({ + start: { iso: "2025-04-28T00:00:00.000Z", precision: "day" }, + end: { iso: "2025-06-12T00:00:00.000Z", precision: "day" }, + }); + }); + + test("reports no end when the end half is not a date", () => { + // "Permanent" is the source telling us there is no deadline. Inventing one + // is the failure this codebase exists to prevent. + const permanent = parseLabelledStartEnd( + "Start: January 24, 2025 End: Permanent", + ); + expect(permanent?.start.iso).toBe("2025-01-24T00:00:00.000Z"); + expect(permanent?.end).toBeNull(); + + expect(parseLabelledStartEnd("Start: January 24, 2025 End: TBD")?.end).toBeNull(); + expect(parseLabelledStartEnd("Start: January 24, 2025")?.end).toBeNull(); + }); + + test("returns null when the start half is not a date", () => { + expect(parseLabelledStartEnd("Start: After maintenance End: TBD")).toBeNull(); + expect(parseLabelledStartEnd("Ends after 30 days of making your account.")).toBeNull(); + }); + + test("requires the colons, so prose containing 'end' does not split", () => { + // Without them, any sentence mentioning an end would look like a boundary. + expect( + parseLabelledStartEnd("Start the quest before August 12, 2026 to end the arc"), + ).toBeNull(); + }); +});