diff --git a/CLAUDE.md b/CLAUDE.md
index 952498d..42a832a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -30,6 +30,7 @@ Svelte 5 + TypeScript + Vite. Public repository.
| Shut-down businesses (owned, not running) | `isRunning` in `lib/properties.ts` (020) |
| Bunker staff on manufacturing/both/research | `staffSplit` in `data/production.ts` (025) |
| Real-time timers: Lucky Wheel, Mansion boost | `data/timers.ts`, `lib/timers.ts` (022) |
+| Weekly events and the Weekly tab | `data/weekly.ts`, `lib/weekly.ts` (decision 027) |
| Overview tab, next events, notifications | `lib/events.ts`, `lib/overview.ts` (dec. 023) |
| Theme tokens | `src/app.css` (decision 009) |
| Link previews (Open Graph), preview image | `index.html`, `scripts/og-image.html` |
diff --git a/README.md b/README.md
index 59e86a5..6112fec 100644
--- a/README.md
+++ b/README.md
@@ -24,7 +24,8 @@ emptying.
collected, spun), and get optional alerts as pop-ups or browser notifications.
- **Resync anytime.** When an estimate drifts from what the game shows, click the value and type
the real one.
-- **Weekly events** (bonuses, discounts) come from a data file in this repo that's updated weekly.
+- **Weekly events** (bonuses, discounts, rotations) come from a data file in this repo that's updated
+ weekly, copied from gta.wiki. They're on the Weekly tab.
Targets the current-gen edition (PC Enhanced, PS5, Xbox Series X|S).
diff --git a/docs/decisions/027-weekly-contents.md b/docs/decisions/027-weekly-contents.md
new file mode 100644
index 0000000..51a66a4
--- /dev/null
+++ b/docs/decisions/027-weekly-contents.md
@@ -0,0 +1,69 @@
+# 027: Weekly events: one typed file per week, its contents, week and source
+
+2026-10-15, issue #16 (format) and #17 (routine). Builds on decision 005.
+
+## Decision
+
+- **One typed TypeScript file, not JSON.** The week is `WEEK` in `src/data/weekly.ts`, typed by
+ `Week` in `src/lib/weekly.ts`, and bundled with the site. The type checker is the schema, and
+ a unit test runs `weekProblems` on it (dates in order, factors above 1, discounts 1 to 100%,
+ known property ids, a gta.wiki source), so `npm run verify` and CI reject a bad week before it
+ merges. No fetch, loading state or runtime validator. This replaces the `public/weekly.json`
+ of issue #16; decision 005 (a committed file, updated by pull request) still holds. Most
+ entries are plain strings, copied as the wiki words them; only what the UI sorts or groups
+ by has fields (a multiplier's factor and property, a discount's kind and percent).
+- **Contents**, per week:
+ - **Money multipliers**: activity, factor (2x, 3x, ...), what it pays (GTA$, RP or both).
+ - **Weekly Challenge**: the objective and its reward.
+ - **Rotations**: Salvage Yard robbery vehicles, FIB Priority File, Kortz Center paintings,
+ Lucky Wheel podium vehicle, Prize Ride (vehicle and condition), LS Car Meet Test Rides,
+ Luxury Autos and Premium Deluxe Motorsport showrooms, Gun Van weapon stock, Premium Race,
+ Time Trial, HSW Time Trial and HSW Premium Test Ride.
+ - **Discounts**: vehicles, properties and Gun Van weapons, each with its percentage (free = 100).
+ - **Gifts and one-off bonuses**: free items, and rewards for doing something this week
+ (e.g. "complete X, get GTA$500,000 within 72 hours"), with any deadline.
+ - **Still running**: seasonal activities carried over from earlier weeks without a bonus
+ this week (e.g. Ghosts Exposed, Phantom Car), names only. Shown below the week's events,
+ more out of the way, in a collapsed "Also running" list.
+ - **Week**: start and end as exact UTC instants, plus the source link.
+- **Left out:**
+ - **GTA+**: no member-only multipliers, discounts or monthly benefits. Where an entry has a
+ separate GTA+ value (e.g. "3x, 6x for GTA+"), only the standard value is kept.
+ - **Daily, map-bound rotations**: the Gun Van's location, and the daily Junk Energy and RC
+ Time Trials. They change every day and only make sense on a map, which LSOS does not have.
+ The Gun Van's weekly stock and discounts stay (they are not tied to a place).
+- **Shown on the Weekly tab only**: a header with the week's dates, a status pill (live with
+ time left, or out of date) and the source links; bonus payouts (factor badge, activity, what
+ it pays) next to the Weekly Challenge and gifts; the rotation and the discounts (by vehicles,
+ properties and Gun Van, then by percentage) as labelled blocks of chips; then a collapsed
+ "Also running" list. The panels keep a fixed order: unlike the business cards, there is
+ nothing to compare between them.
+- **No Breaking News bar.** Issue #16 asked for a ticker on every tab; it was built and taken
+ out again before merging: more distracting than helpful. A multiplier names its `property` id when it pays through one (e.g. bounties
+ through the Bail Office), so owners see it first.
+- **Out of date:** once `now >= end` the pill says "Out of date" and a warning says the events
+ are last week's; the old week stays readable below it, greyed and dimmed, instead of shown as
+ current or hidden.
+- **The week** runs Thursday to the next Thursday. The file stores explicit `start` and `end`
+ instants and the site uses only those (staleness: `now >= end`), never a weekday rule: some
+ events ran 12 to 14 days (gta.wiki, Lucky Wheel). Instants are written as Thursday 2:00 AM US
+ Pacific time: 09:00 UTC while the US is on daylight saving time, 10:00 UTC otherwise.
+ Community guides give this time; no official source states it, so it is a convention for
+ writing the file, not a game fact used in logic.
+- **Source: gta.wiki**, the weekly event page (`Category:Weekly events`, infobox
+ `type = weeklyevent`, e.g. "Halloween 2026 (Week 3)"). The routine reads its wikitext through
+ the MediaWiki API (`https://gta.wiki/api.php?action=parse&page=
&prop=wikitext&format=json`).
+ The page's Rockstar Newswire link is kept as a second link in the file.
+- **Routine time** (#17): Thursday late afternoon, about 16:00 UTC, so the wiki page is complete.
+
+## Why
+
+- The contents are the ones that matter to someone planning a session's money; GTA+ and map
+ widgets were declined for this project.
+- gta.wiki is trusted for game facts (CLAUDE.md) and its weekly pages list every category above
+ under fixed headings, with a previous and next week link. The Rockstar Newswire needs
+ JavaScript to show its text, its API was not reachable from the cloud environment, and it
+ often posts one article for a whole month of weeks.
+- Timing seen on 2026-10-15: the week's wiki page was created on 2026-10-14 at 23:53 UTC (before
+ the reset, likely from leaks) and its Gun Van, Salvage Yard and Prize Ride parts were filled in
+ between 11:52 and 11:57 UTC on Thursday. A run right after the reset would miss them.
diff --git a/docs/decisions/README.md b/docs/decisions/README.md
index 1735618..a1c0684 100644
--- a/docs/decisions/README.md
+++ b/docs/decisions/README.md
@@ -37,3 +37,4 @@ free number, add one line below. **Replacing one:** write a new decision, then m
- [024](024-several-tabs.md) Several tabs: a tab takes the state another tab saves (`storage` event); only the tab that last started counting counts play time (`BroadcastChannel`)
- [025](025-bunker-staff.md) Bunker staff on Manufacturing, Both or Research: a stock share (Both 0.5, Research 0) on the sourced rates, supplies used as fast on every option (est.), research progress not tracked
- [026](026-movable-cards-everywhere.md) Movable cards on Businesses, Safes and Timers (`ui/CardGrid`): picked up by the title bar; swap by the dragged card's middle, 10% in; orders saved per tab
+- [027](027-weekly-contents.md) Weekly events: typed `src/data/weekly.ts` (no JSON, no fetch), checked by a test; Weekly tab only (no news bar); out of date: warning over the dimmed old week; contents: multipliers, Weekly Challenge, rotations, discounts, gifts, a less prominent "Also running" list; no GTA+, no daily map-bound items; explicit UTC start/end (Thursday 2:00 AM US Pacific); source gta.wiki weekly page; routine Thursday ~16:00 UTC
diff --git a/docs/weekly-update.md b/docs/weekly-update.md
new file mode 100644
index 0000000..5f86412
--- /dev/null
+++ b/docs/weekly-update.md
@@ -0,0 +1,35 @@
+# Writing a week's events
+
+Each Thursday's GTA Online events go into `src/data/weekly.ts` (decision 027), replaced in full
+by one pull request. By hand or by the weekly routine (#17), the steps are the same.
+
+1. **Wait for the page.** Find the week's page on gta.wiki: the newest in
+ [Category:Weekly events](https://gta.wiki/w/Category:Weekly_events), e.g. "Halloween 2026
+ (Week 3)". It is filled in during Thursday; write the file in the late afternoon (UTC), when
+ the Gun Van, Salvage Yard and Prize Ride parts are in. Raw text:
+ `https://gta.wiki/api.php?action=parse&page=&prop=wikitext&format=json`.
+2. **Week.** `title` is the page title, `source` its URL (`https://gta.wiki/w/`),
+ `newswire` the Rockstar link in its infobox, if any. `start` is the Thursday the page names at
+ 2:00 AM US Pacific, `end` the day after its last day at the same time: `09:00:00Z` while the
+ US is on daylight saving time (mid-March to early November), `10:00:00Z` otherwise. For a week
+ longer than seven days, `end` follows the page's dates.
+3. **Copy the sections**, standard values only. Leave out anything GTA+ only, and the daily
+ Junk Energy and RC Time Trials.
+
+ | Wiki section | Field |
+ | ------------------------------------ | ------------------------------------------------- |
+ | Standard Bonuses: "3x GTA$ and RP …" | `multipliers` (`factor`, `pays`, `activity`) |
+ | Weekly Challenge | `challenge` (`task`, `reward`) |
+ | Bonuses: free items, one-off rewards | `gifts`, one line each with its deadline |
+ | Job Updates, Standard Bonuses, Races | `rotations` (`label`, `items`, optional `note`) |
+ | Discounts | `discounts` (`kind`, `item`, `percent`; free 100) |
+ | Job Updates: still-available events | `alsoRunning`, only those with no bonus this week |
+
+ A multiplier that pays through a property LSOS knows gets that `property` id from
+ `src/data/properties.ts` (e.g. bounties: `bail-office`), so owners see it first. The Prize
+ Ride's challenge is on the wiki's Prize Ride Challenge page; put it in the rotation's `note`.
+ Keep the rotation labels of the previous week, so the tab reads the same each week.
+
+4. **Check.** `npm run verify 2>&1 | tail -n 5`. The type and the `weekly` test catch missing
+ fields, an end before the start, odd numbers and unknown property ids.
+5. **Pull request** titled `Weekly events: `, linking the wiki page.
diff --git a/src/App.svelte b/src/App.svelte
index a26b409..2cadefb 100644
--- a/src/App.svelte
+++ b/src/App.svelte
@@ -8,11 +8,13 @@
import SaveStatus from './components/SaveStatus.svelte'
import Settings from './components/Settings.svelte'
import Timers from './components/Timers.svelte'
+ import Weekly from './components/Weekly.svelte'
import TabBar from './components/ui/TabBar.svelte'
import { GOODS } from './data/nightclub'
import { PRODUCTION } from './data/production'
import { PROPERTIES } from './data/properties'
import { SAFES } from './data/safes'
+ import { WEEK } from './data/weekly'
import { clocks } from './lib/clock.svelte'
import { dismissPopup, overview, quick, undoQuick } from './lib/overview.svelte'
import './lib/production.svelte'
@@ -125,6 +127,8 @@
order={store.state.timerOrder}
onorder={(order) => (store.state.timerOrder = order)}
/>
+ {:else if active === 'weekly'}
+
{:else if active === 'settings'}
{:else}
diff --git a/src/components/Weekly.svelte b/src/components/Weekly.svelte
new file mode 100644
index 0000000..c8b67cc
--- /dev/null
+++ b/src/components/Weekly.svelte
@@ -0,0 +1,440 @@
+
+
+
+
+
+ {#if status === 'ended'}
+
+ This week has ended and the new one is not in yet. The events below are last week's and may no
+ longer apply.
+
+ {/if}
+
+
+
+
+
+ {#each multipliers as multiplier (multiplier.activity)}
+ {@const mine = multiplier.property !== undefined && isOwned(owned, multiplier.property)}
+ -
+ = 3}>{multiplier.factor}x
+
+ {multiplier.activity}
+ {#if mine}Owned{/if}
+
+ {multiplier.pays}
+
+ {:else}
+ - No bonus payouts this week.
+ {/each}
+
+
+
+
+ {#if week.challenge}
+
+ Weekly Challenge
+ {week.challenge.task}
+ {week.challenge.reward}
+
+ {/if}
+ {#if week.gifts.length > 0}
+
+ {#each week.gifts as gift (gift)}
+ - {gift}
+ {/each}
+
+ {/if}
+
+
+
+
+
+ {#each week.rotations as rotation (rotation.label)}
+
+ {rotation.label}
+
+ {#each rotation.items as item (item)}
+ - {item}
+ {/each}
+
+ {#if rotation.note}{rotation.note}
{/if}
+
+ {/each}
+
+
+
+
+
+ {#each KINDS as { kind, label } (kind)}
+ {@const tiers = discountTiers(week.discounts, kind)}
+ {#if tiers.length > 0}
+
+ {label}
+ {#each tiers as tier (tier.percent)}
+
+
= 100}>
+ {discountText({ kind, item: '', percent: tier.percent })}
+
+
+ {#each tier.items as item (item)}
+ - {item}
+ {/each}
+
+
+ {/each}
+
+ {/if}
+ {/each}
+
+
+
+
+ {#if week.alsoRunning.length > 0}
+
+ Also running, no bonus this week ({week.alsoRunning.length})
+
+ {#each week.alsoRunning as item (item)}
+ - {item}
+ {/each}
+
+
+ {/if}
+
+
+
diff --git a/src/data/weekly.ts b/src/data/weekly.ts
new file mode 100644
index 0000000..fe0ce4b
--- /dev/null
+++ b/src/data/weekly.ts
@@ -0,0 +1,118 @@
+// This week's GTA Online events (issue #16, decision 027), replaced by a pull request each week.
+// Copied from the gta.wiki weekly event page in `source`; how to write it: docs/weekly-update.md.
+
+import type { Week } from '../lib/weekly'
+
+export const WEEK: Week = {
+ title: 'Halloween 2026 (Week 3)',
+ start: '2026-10-15T09:00:00Z',
+ end: '2026-10-22T09:00:00Z',
+ source: 'https://gta.wiki/w/Halloween_2026_(Week_3)',
+ newswire:
+ 'https://www.rockstargames.com/newswire/article/39a22k25434a53/experience-halloween-thrills-all-throughout-october-in-gta-online',
+ multipliers: [
+ { activity: 'Ludendorff Cemetery Survival', factor: 3, pays: 'GTA$ and RP' },
+ { activity: 'Cayo Perico Survival', factor: 3, pays: 'GTA$ and RP' },
+ { activity: 'Alien Survivals', factor: 3, pays: 'GTA$ and RP' },
+ { activity: 'Slasher', factor: 3, pays: 'GTA$ and RP' },
+ { activity: 'San Andreas Super Sport Series', factor: 3, pays: 'GTA$ and RP' },
+ { activity: 'Random Transform Races', factor: 3, pays: 'GTA$ and RP' },
+ { activity: 'Community Race Series', factor: 3, pays: 'GTA$ and RP' },
+ {
+ activity: 'Standard and Most Wanted Bounties',
+ factor: 2,
+ pays: 'GTA$ and RP',
+ property: 'bail-office',
+ },
+ { activity: 'Dispatch Work', factor: 2, pays: 'GTA$ and RP' },
+ { activity: 'UFO Business Battle', factor: 2, pays: 'GTA$ and RP' },
+ { activity: "Jack O' Lanterns", factor: 2, pays: 'GTA$ and RP' },
+ { activity: 'Ghosts Exposed', factor: 2, pays: 'GTA$' },
+ ],
+ challenge: {
+ task: 'Win 3 Races',
+ reward: 'GTA$100,000 and the Orange Glow Skeleton Onesie',
+ },
+ gifts: [
+ "Complete Don't Fuck With Dre this week: GTA$500,000 within 72 hours",
+ 'Complete all Weekly Challenges through November 4: GTA$2,000,000 within 72 hours',
+ 'Free Butchery and Other Hobbies T-Shirt at any clothing store',
+ ],
+ rotations: [
+ { label: 'Lucky Wheel podium', items: ['Grotti LSCM Cheetah Classic'] },
+ {
+ label: 'Prize Ride',
+ items: ['Dinka Kanjo SJ'],
+ note: 'Place Top 5 in any LS Car Meet Series race 1 time this week',
+ },
+ {
+ label: 'Salvage Yard robberies',
+ items: ['BF Weevil', 'Grotti Itali RSX', 'Albany Fränken Stange'],
+ },
+ { label: 'FIB Priority File', items: ['The Brute Force File'] },
+ {
+ label: 'Kortz Center Heist paintings',
+ items: ['Twindifference', 'Teckels', 'A Cast of Characters'],
+ },
+ {
+ label: 'LS Car Meet Test Rides',
+ items: ['Declasse Vamos', 'Dewbauchee Seven-70', 'Ocelot Locust'],
+ },
+ { label: 'Luxury Autos', items: ['Dewbauchee Rapid GT X', 'Übermacht Sentinel XS4'] },
+ {
+ label: 'Premium Deluxe Motorsport',
+ items: [
+ 'LCC Sanctus',
+ 'Albany Fränken Stange',
+ 'Albany Brigham',
+ 'Declasse Tornado Rat Rod',
+ 'Albany Lurcher',
+ ],
+ },
+ { label: "Hao's Premium Test Ride", items: ['Maibatsu MonstroCiti'] },
+ {
+ label: 'Gun Van stock',
+ items: [
+ 'Baseball Bat',
+ 'The Shocker',
+ 'Service Carbine',
+ 'Up-n-Atomizer',
+ 'Unholy Hellbringer',
+ 'Precision Rifle',
+ 'Combat Shotgun',
+ 'El Strickler',
+ 'Pipe Bomb',
+ ],
+ },
+ { label: 'Premium Race', items: ['Criminal Records (Super)'] },
+ { label: 'Time Trial', items: ['Coast to Coast'] },
+ { label: 'HSW Time Trial', items: ['Pacific Bluffs'] },
+ ],
+ discounts: [
+ { kind: 'vehicle', item: 'Albany Brigham', percent: 40 },
+ { kind: 'vehicle', item: 'Albany Fränken Stange', percent: 40 },
+ { kind: 'vehicle', item: 'Albany Lurcher', percent: 40 },
+ { kind: 'vehicle', item: 'Declasse Tornado Rat Rod', percent: 40 },
+ { kind: 'vehicle', item: 'LCC Sanctus', percent: 40 },
+ { kind: 'vehicle', item: 'Annis Hardy', percent: 30 },
+ { kind: 'vehicle', item: 'BF Raptor', percent: 30 },
+ { kind: 'vehicle', item: 'Buckingham Miljet', percent: 30 },
+ { kind: 'vehicle', item: 'Buckingham Tug', percent: 30 },
+ { kind: 'vehicle', item: 'Dinka Sugoi', percent: 30 },
+ { kind: 'vehicle', item: 'Karin Calico GTF', percent: 30 },
+ { kind: 'vehicle', item: 'Nagasaki Weaponized Dinghy', percent: 30 },
+ { kind: 'vehicle', item: 'Vapid Winky', percent: 30 },
+ { kind: 'vehicle', item: 'Western Annihilator', percent: 30 },
+ { kind: 'property', item: 'Agencies, upgrades and modifications', percent: 40 },
+ { kind: 'weapon', item: 'Baseball Bat', percent: 100 },
+ { kind: 'weapon', item: 'The Shocker', percent: 100 },
+ { kind: 'weapon', item: 'Service Carbine', percent: 30 },
+ ],
+ alsoRunning: [
+ 'Cerberus Surprise',
+ 'Slashers Event',
+ 'Possessed Animals',
+ 'Phantom Car',
+ 'Peyote Plants',
+ ],
+}
diff --git a/src/lib/weekly.test.ts b/src/lib/weekly.test.ts
new file mode 100644
index 0000000..ed4c6dc
--- /dev/null
+++ b/src/lib/weekly.test.ts
@@ -0,0 +1,117 @@
+import { describe, expect, it } from 'vitest'
+import { PROPERTIES } from '../data/properties'
+import { WEEK } from '../data/weekly'
+import { setOwned } from './properties'
+import {
+ discountText,
+ discountTiers,
+ sortMultipliers,
+ timeLeftText,
+ weekProblems,
+ weekStatus,
+ type Week,
+} from './weekly'
+
+const HOUR = 60 * 60_000
+const IDS = PROPERTIES.map((property) => property.id)
+
+const SAMPLE: Week = {
+ title: 'Sample',
+ start: '2026-10-15T09:00:00Z',
+ end: '2026-10-22T09:00:00Z',
+ source: 'https://gta.wiki/w/Sample',
+ multipliers: [
+ { activity: 'Dispatch Work', factor: 2, pays: 'GTA$ and RP' },
+ { activity: 'Slasher', factor: 3, pays: 'GTA$ and RP' },
+ { activity: 'Bounties', factor: 2, pays: 'GTA$', property: 'bail-office' },
+ ],
+ gifts: ['Free T-shirt'],
+ rotations: [{ label: 'Lucky Wheel podium', items: ['Some car'] }],
+ discounts: [{ kind: 'weapon', item: 'Baseball Bat', percent: 100 }],
+ alsoRunning: [],
+}
+
+describe('the committed week', () => {
+ it('has no problems', () => {
+ expect(weekProblems(WEEK, IDS)).toEqual([])
+ })
+})
+
+describe('weekStatus', () => {
+ it('is upcoming before the start, current until the end, then ended', () => {
+ expect(weekStatus(SAMPLE, Date.parse('2026-10-15T08:59:59Z'))).toBe('upcoming')
+ expect(weekStatus(SAMPLE, Date.parse('2026-10-15T09:00:00Z'))).toBe('current')
+ expect(weekStatus(SAMPLE, Date.parse('2026-10-22T08:59:59Z'))).toBe('current')
+ expect(weekStatus(SAMPLE, Date.parse('2026-10-22T09:00:00Z'))).toBe('ended')
+ })
+})
+
+describe('sortMultipliers', () => {
+ it('puts the highest first', () => {
+ expect(sortMultipliers(SAMPLE.multipliers, {}).map((m) => m.activity)).toEqual([
+ 'Slasher',
+ 'Dispatch Work',
+ 'Bounties',
+ ])
+ })
+
+ it('puts the ones paid through an owned property ahead of everything', () => {
+ const owned = setOwned({}, 'bail-office', true)
+ expect(sortMultipliers(SAMPLE.multipliers, owned)[0]?.activity).toBe('Bounties')
+ })
+})
+
+describe('timeLeftText', () => {
+ it('shows days and hours, or hours only in the last day', () => {
+ expect(timeLeftText((6 * 24 + 16) * HOUR + 59 * 60_000)).toBe('6d 16h left')
+ expect(timeLeftText(3 * HOUR + 1)).toBe('3h left')
+ expect(timeLeftText(-5)).toBe('0h left')
+ })
+})
+
+describe('discountText', () => {
+ it('says Free at 100%', () => {
+ expect(discountText({ kind: 'weapon', item: 'Bat', percent: 100 })).toBe('Free')
+ expect(discountText({ kind: 'vehicle', item: 'Car', percent: 40 })).toBe('40% off')
+ })
+})
+
+describe('discountTiers', () => {
+ it('groups one kind by percentage, highest first', () => {
+ expect(
+ discountTiers(
+ [
+ { kind: 'vehicle', item: 'A', percent: 30 },
+ { kind: 'weapon', item: 'Bat', percent: 100 },
+ { kind: 'vehicle', item: 'B', percent: 40 },
+ { kind: 'vehicle', item: 'C', percent: 30 },
+ ],
+ 'vehicle',
+ ),
+ ).toEqual([
+ { percent: 40, items: ['B'] },
+ { percent: 30, items: ['A', 'C'] },
+ ])
+ })
+})
+
+describe('weekProblems', () => {
+ it('finds what the type cannot', () => {
+ const bad: Week = {
+ ...SAMPLE,
+ end: SAMPLE.start,
+ source: 'https://example.com',
+ multipliers: [{ activity: 'X', factor: 1, pays: 'RP', property: 'nope' }],
+ discounts: [{ kind: 'vehicle', item: 'Car', percent: 0 }],
+ rotations: [{ label: 'Empty', items: [] }],
+ }
+ expect(weekProblems(bad, IDS)).toEqual([
+ 'end is not after start',
+ 'multiplier "X" is not above 1',
+ 'multiplier "X" names unknown property "nope"',
+ 'discount "Car" is not 1-100%',
+ 'rotation "Empty" lists nothing',
+ 'source is not a gta.wiki page',
+ ])
+ })
+})
diff --git a/src/lib/weekly.ts b/src/lib/weekly.ts
new file mode 100644
index 0000000..59366c7
--- /dev/null
+++ b/src/lib/weekly.ts
@@ -0,0 +1,120 @@
+// This week's GTA Online events (issue #16, decisions 005 and 027). The week itself is a typed
+// object in `src/data/weekly.ts`, replaced by a pull request each week; this file holds its shape
+// and the pure logic around it.
+
+import type { OwnedProperties } from './properties'
+import { isOwned } from './properties'
+
+/** One week of events, copied from its gta.wiki weekly event page. */
+export interface Week {
+ /** Page title on gta.wiki, e.g. "Halloween 2026 (Week 3)". */
+ title: string
+ /** When the week starts and ends, as UTC ISO instants (Thursday 2:00 AM US Pacific). */
+ start: string
+ end: string
+ /** The gta.wiki page the week was copied from. */
+ source: string
+ /** Rockstar's Newswire article, when the page links one. */
+ newswire?: string
+ /** Activities paying more than usual. GTA+ only values are left out. */
+ multipliers: Multiplier[]
+ /** The Weekly Challenge, when the page names one. */
+ challenge?: { task: string; reward: string }
+ /** Free items and one-off rewards, one line each, with any deadline in the text. */
+ gifts: string[]
+ /** What changed this week: podium, Prize Ride, Salvage Yard, showrooms, Gun Van stock, ... */
+ rotations: Rotation[]
+ discounts: Discount[]
+ /** Seasonal activities still running from earlier weeks without a bonus this week. */
+ alsoRunning: string[]
+}
+
+export interface Multiplier {
+ activity: string
+ /** 2 for 2x, 3 for 3x, ... */
+ factor: number
+ pays: 'GTA$ and RP' | 'GTA$' | 'RP'
+ /** The property it pays through, if any (`src/data/properties.ts` id): owners see it first. */
+ property?: string
+}
+
+export interface Rotation {
+ /** What rotates, e.g. "Lucky Wheel podium". */
+ label: string
+ items: string[]
+ /** What it takes, e.g. the Prize Ride's challenge. */
+ note?: string
+}
+
+export interface Discount {
+ kind: 'vehicle' | 'property' | 'weapon'
+ item: string
+ /** Percent off; 100 is free. */
+ percent: number
+}
+
+export type WeekStatus = 'upcoming' | 'current' | 'ended'
+
+/** Whether the week has started and whether it is over at `now` (ms). */
+export function weekStatus(week: Pick, now: number): WeekStatus {
+ if (now < Date.parse(week.start)) return 'upcoming'
+ return now < Date.parse(week.end) ? 'current' : 'ended'
+}
+
+/** "Free" or "40% off" */
+export function discountText(discount: Discount): string {
+ return discount.percent >= 100 ? 'Free' : `${discount.percent}% off`
+}
+
+/** The bonus payouts, the ones paid through an owned property first, then highest first. */
+export function sortMultipliers(
+ multipliers: readonly Multiplier[],
+ owned: OwnedProperties,
+): Multiplier[] {
+ const ownsIt = (m: Multiplier) => m.property !== undefined && isOwned(owned, m.property)
+ return [...multipliers].sort(
+ (a, b) => Number(ownsIt(b)) - Number(ownsIt(a)) || b.factor - a.factor,
+ )
+}
+
+/** Time left in the week, e.g. "6d 16h left" or "3h left". */
+export function timeLeftText(ms: number): string {
+ const hours = Math.max(0, Math.floor(ms / 3_600_000))
+ const days = Math.floor(hours / 24)
+ return days > 0 ? `${days}d ${hours % 24}h left` : `${hours}h left`
+}
+
+/** Discounts of one kind, by percentage (highest first), keeping the file's item order. */
+export function discountTiers(
+ discounts: readonly Discount[],
+ kind: Discount['kind'],
+): { percent: number; items: string[] }[] {
+ const tiers = new Map()
+ for (const d of discounts) {
+ if (d.kind === kind) tiers.set(d.percent, [...(tiers.get(d.percent) ?? []), d.item])
+ }
+ return [...tiers].sort(([a], [b]) => b - a).map(([percent, items]) => ({ percent, items }))
+}
+
+/** Problems with a week's data that the type does not catch; empty when it is fine. */
+export function weekProblems(week: Week, propertyIds: readonly string[]): string[] {
+ const problems: string[] = []
+ const start = Date.parse(week.start)
+ const end = Date.parse(week.end)
+ if (Number.isNaN(start) || Number.isNaN(end)) problems.push('start or end is not a date')
+ else if (end <= start) problems.push('end is not after start')
+ for (const m of week.multipliers) {
+ if (!(m.factor > 1)) problems.push(`multiplier "${m.activity}" is not above 1`)
+ if (m.property !== undefined && !propertyIds.includes(m.property)) {
+ problems.push(`multiplier "${m.activity}" names unknown property "${m.property}"`)
+ }
+ }
+ for (const d of week.discounts) {
+ if (!(d.percent > 0 && d.percent <= 100)) problems.push(`discount "${d.item}" is not 1-100%`)
+ }
+ for (const r of week.rotations) {
+ if (r.items.length === 0) problems.push(`rotation "${r.label}" lists nothing`)
+ }
+ if (!/^https:\/\/gta\.wiki\//.test(week.source)) problems.push('source is not a gta.wiki page')
+ return problems
+}