From aa27584930441d24df89eec67dd0f7a0a8aee94c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 16:20:07 +0000 Subject: [PATCH 1/4] Weekly events: decide file contents, week bounds and source (#16) Decision 027: what public/weekly.json holds (multipliers, Weekly Challenge, rotations, discounts, gifts), what it leaves out (GTA+, daily map-bound items), explicit UTC start/end per week, gta.wiki as the source and the routine's run time. Handoff for building the schema, tab and news bar. Co-Authored-By: Claude Opus 5.5 --- docs/decisions/027-weekly-contents.md | 46 +++++++++++++++++ docs/decisions/README.md | 1 + docs/handoff/16-weekly-events.md | 74 +++++++++++++++++++++++++++ 3 files changed, 121 insertions(+) create mode 100644 docs/decisions/027-weekly-contents.md create mode 100644 docs/handoff/16-weekly-events.md diff --git a/docs/decisions/027-weekly-contents.md b/docs/decisions/027-weekly-contents.md new file mode 100644 index 0000000..ad92e8f --- /dev/null +++ b/docs/decisions/027-weekly-contents.md @@ -0,0 +1,46 @@ +# 027: What the weekly file holds, its week and its source + +2026-10-15, issue #16 (format) and #17 (routine). Builds on decision 005. + +## Decision + +- **Contents of `public/weekly.json`**, 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. + - **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). +- **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..5cef199 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 file: multipliers, Weekly Challenge, rotations, discounts, gifts; 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/handoff/16-weekly-events.md b/docs/handoff/16-weekly-events.md new file mode 100644 index 0000000..8212791 --- /dev/null +++ b/docs/handoff/16-weekly-events.md @@ -0,0 +1,74 @@ +# 16: Weekly events: data file, Weekly tab and Breaking News bar + +**Status:** scope and source decided (decision 027); no code yet. + +## Goal + +Show this week's GTA Online events from a committed `public/weekly.json` ([#16]): a typed, +validated schema, a Weekly tab, a Breaking News bar on every tab, a stale-data notice, and a +short guide on writing a week's file by hand. The routine that writes the file is #17, not this +PR. + +[#16]: https://github.com/ToothyDev/LSOS/issues/16 + +## Read first (only these) + +- Issue #16 (body) +- Decision 005 and 027 +- `src/lib/tabs.ts` (the `weekly` tab id already exists) and the tab switch in `src/App.svelte` +- `src/lib/clock.ts` for how `now` reaches logic (decision 003, 008) +- One existing `src/data/` file with sources, e.g. `src/data/timers.ts`, for the comment style +- gta.wiki page "Halloween 2026 (Week 3)" wikitext as the sample week (API URL in decision 027) + +## State now + +Only docs: decision 027 and this handoff. The maintainer's answers: + +- Multipliers, Weekly Challenge, rotations, discounts, gifts and one-offs: in. +- GTA+: "honestly no, screw GTA+". Leave all of it out. +- Daily map-bound items (Gun Van location, daily time trials): out; "not sure i wanna make a + whole map widget for it". +- "Events" (the still-running seasonal activities): "depends on what you mean exactly". Open, + see below. +- Reset time: "pick what fits best" (picked: explicit instants, written as Thursday 2:00 AM US + Pacific; decision 027). + +## Findings + +Sample week, gta.wiki "Halloween 2026 (Week 3)", 2026-10-15 to 2026-10-21. Its headings, which +the schema can mirror: + +- `Job Updates`: Salvage Yard vehicles (BF Weevil, Grotti Itali RSX, Albany Fränken Stange), + Kortz Center paintings, list of returning seasonal events. +- `Bonuses`: one-offs (complete "Don't Fuck With Dre" this week: GTA$500,000 within 72 hours; + all Weekly Challenges through November 4: GTA$2,000,000; free T-shirt), `Weekly Challenge` + (win 3 races: GTA$100,000), `FIB Priority File` (The Brute Force File), `Standard Bonuses` + (podium LSCM Cheetah Classic, Prize Ride Kanjo SJ, Test Rides, showrooms, Gun Van stock, + then lines like "3x GTA$ and RP from Slasher" and "2x GTA$ and RP from Dispatch Work"). +- `Discounts`: vehicles ("Albany Brigham – 40% off", some with a trade price too), properties + ("Agencies including upgrades and modifications – 40% off"), Gun Van weapons (FREE, 30% off). +- `Races and Time Trials`: Premium Race, Time Trial, HSW Time Trial (keep); Junk Energy and RC + Time Trials per day (drop). +- The Enhanced-only section (HSW) is the only console split on the page; LSOS ignores Legacy + (CLAUDE.md non-goals), so HSW items can be kept without a platform flag. +- A multiplier names an activity; some match owned things (Bail Office bounties, business + sales, Business Battles). The Breaking News bar should rank those first; how to match an + activity to a property id is still to design (perhaps an optional `property` id per entry). + +## Next steps + +1. Schema in `src/lib/weekly.ts`: types plus a validator (pure TS, unit-tested) that rejects a + malformed file; staleness from `start`/`end` and an injected `now`. +2. A first `public/weekly.json` for 2026-10-15 to 2026-10-22 from the sample page. +3. Weekly tab: grouped sections in the page's order (multipliers, challenge, gifts, rotations, + discounts). +4. Breaking News bar on every tab: multipliers and gifts, owned matches first; stale notice + when `now >= end`. +5. Contributor section on writing a week by hand (in the PR or `docs/`). +6. `npm run verify`, then the `finish-pr` skill. + +## Open questions + +- "Events": show the still-running seasonal activities (Ghosts Exposed, Phantom Car, ...) as a + plain "also running" list without bonuses, or leave them out? Ask the maintainer. +- Exact reset time is not officially sourced; check once against a real reset. From be83e834a5dfd180e6736570dbcd32cbde698e55 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 15 Oct 2026 16:34:20 +0000 Subject: [PATCH 2/4] Weekly events: keep still-running seasonal activities, out of the way (#16) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/decisions/027-weekly-contents.md | 4 ++++ docs/decisions/README.md | 2 +- docs/handoff/16-weekly-events.md | 10 ++++------ 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/docs/decisions/027-weekly-contents.md b/docs/decisions/027-weekly-contents.md index ad92e8f..eeb3976 100644 --- a/docs/decisions/027-weekly-contents.md +++ b/docs/decisions/027-weekly-contents.md @@ -14,6 +14,10 @@ - **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 (e.g. a collapsed or muted "Also running" list), and never in the + Breaking News bar. - **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 diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 5cef199..c434a09 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -37,4 +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 file: multipliers, Weekly Challenge, rotations, discounts, gifts; 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 +- [027](027-weekly-contents.md) Weekly file: 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/handoff/16-weekly-events.md b/docs/handoff/16-weekly-events.md index 8212791..1c06a2b 100644 --- a/docs/handoff/16-weekly-events.md +++ b/docs/handoff/16-weekly-events.md @@ -1,6 +1,6 @@ # 16: Weekly events: data file, Weekly tab and Breaking News bar -**Status:** scope and source decided (decision 027); no code yet. +**Status:** scope, source and still-running list decided (decision 027); no code yet. ## Goal @@ -28,8 +28,8 @@ Only docs: decision 027 and this handoff. The maintainer's answers: - GTA+: "honestly no, screw GTA+". Leave all of it out. - Daily map-bound items (Gun Van location, daily time trials): out; "not sure i wanna make a whole map widget for it". -- "Events" (the still-running seasonal activities): "depends on what you mean exactly". Open, - see below. +- Still-running seasonal activities (Ghosts Exposed, Phantom Car, ...): "add them but slightly + more out of the way than the actual weekly events". - Reset time: "pick what fits best" (picked: explicit instants, written as Thursday 2:00 AM US Pacific; decision 027). @@ -61,7 +61,7 @@ the schema can mirror: malformed file; staleness from `start`/`end` and an injected `now`. 2. A first `public/weekly.json` for 2026-10-15 to 2026-10-22 from the sample page. 3. Weekly tab: grouped sections in the page's order (multipliers, challenge, gifts, rotations, - discounts). + discounts), then a less prominent "Also running" list (collapsed or muted) at the bottom. 4. Breaking News bar on every tab: multipliers and gifts, owned matches first; stale notice when `now >= end`. 5. Contributor section on writing a week by hand (in the PR or `docs/`). @@ -69,6 +69,4 @@ the schema can mirror: ## Open questions -- "Events": show the still-running seasonal activities (Ghosts Exposed, Phantom Car, ...) as a - plain "also running" list without bonuses, or leave them out? Ask the maintainer. - Exact reset time is not officially sourced; check once against a real reset. From ce0f02118afd04a7522c38c563bea012cb032120 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 15 Oct 2026 16:39:33 +0000 Subject: [PATCH 3/4] Weekly tab and Breaking News bar from a typed weekly data file (#16) The week lives in src/data/weekly.ts, typed by Week in lib/weekly.ts and checked by a unit test (weekProblems), so verify rejects a bad week. The Weekly tab shows bonus payouts (owned first), challenge and gifts, the rotation, discounts and a collapsed "Also running" list; the Breaking News bar scrolls the bonuses and gifts on every tab and says when the week is out of date. First week: Halloween 2026 (Week 3) from gta.wiki. Guide for writing a week by hand in docs/weekly-update.md; handoff removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- CLAUDE.md | 1 + README.md | 3 +- docs/decisions/027-weekly-contents.md | 20 ++- docs/decisions/README.md | 2 +- docs/handoff/16-weekly-events.md | 72 -------- docs/weekly-update.md | 35 ++++ src/App.svelte | 6 + src/components/NewsBar.svelte | 132 ++++++++++++++ src/components/Weekly.svelte | 247 ++++++++++++++++++++++++++ src/data/weekly.ts | 118 ++++++++++++ src/lib/weekly.test.ts | 82 +++++++++ src/lib/weekly.ts | 111 ++++++++++++ 12 files changed, 753 insertions(+), 76 deletions(-) delete mode 100644 docs/handoff/16-weekly-events.md create mode 100644 docs/weekly-update.md create mode 100644 src/components/NewsBar.svelte create mode 100644 src/components/Weekly.svelte create mode 100644 src/data/weekly.ts create mode 100644 src/lib/weekly.test.ts create mode 100644 src/lib/weekly.ts diff --git a/CLAUDE.md b/CLAUDE.md index 952498d..a37dfd2 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, Weekly tab, Breaking News bar | `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..bb911b7 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. A Breaking News bar shows the bonuses on every 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 index eeb3976..5af5896 100644 --- a/docs/decisions/027-weekly-contents.md +++ b/docs/decisions/027-weekly-contents.md @@ -1,10 +1,18 @@ -# 027: What the weekly file holds, its week and its source +# 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 -- **Contents of `public/weekly.json`**, per week: +- **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, @@ -25,6 +33,14 @@ - **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 (bonus payouts with owned ones first, challenge and gifts, + rotation, discounts by vehicles, properties and Gun Van; then a collapsed "Also running" list) + and in a Breaking News bar on every tab (multipliers, then gifts; links to the Weekly tab). + The Weekly panels keep a fixed order: unlike the business cards, there is nothing to compare + between them. 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 bar says the weekly events are out of date and the tab + shows a warning over dimmed panels, instead of old bonuses as current. - **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 diff --git a/docs/decisions/README.md b/docs/decisions/README.md index c434a09..798046c 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -37,4 +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 file: 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 +- [027](027-weekly-contents.md) Weekly events: typed `src/data/weekly.ts` (no JSON, no fetch), checked by a test; Weekly tab and Breaking News bar; 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/handoff/16-weekly-events.md b/docs/handoff/16-weekly-events.md deleted file mode 100644 index 1c06a2b..0000000 --- a/docs/handoff/16-weekly-events.md +++ /dev/null @@ -1,72 +0,0 @@ -# 16: Weekly events: data file, Weekly tab and Breaking News bar - -**Status:** scope, source and still-running list decided (decision 027); no code yet. - -## Goal - -Show this week's GTA Online events from a committed `public/weekly.json` ([#16]): a typed, -validated schema, a Weekly tab, a Breaking News bar on every tab, a stale-data notice, and a -short guide on writing a week's file by hand. The routine that writes the file is #17, not this -PR. - -[#16]: https://github.com/ToothyDev/LSOS/issues/16 - -## Read first (only these) - -- Issue #16 (body) -- Decision 005 and 027 -- `src/lib/tabs.ts` (the `weekly` tab id already exists) and the tab switch in `src/App.svelte` -- `src/lib/clock.ts` for how `now` reaches logic (decision 003, 008) -- One existing `src/data/` file with sources, e.g. `src/data/timers.ts`, for the comment style -- gta.wiki page "Halloween 2026 (Week 3)" wikitext as the sample week (API URL in decision 027) - -## State now - -Only docs: decision 027 and this handoff. The maintainer's answers: - -- Multipliers, Weekly Challenge, rotations, discounts, gifts and one-offs: in. -- GTA+: "honestly no, screw GTA+". Leave all of it out. -- Daily map-bound items (Gun Van location, daily time trials): out; "not sure i wanna make a - whole map widget for it". -- Still-running seasonal activities (Ghosts Exposed, Phantom Car, ...): "add them but slightly - more out of the way than the actual weekly events". -- Reset time: "pick what fits best" (picked: explicit instants, written as Thursday 2:00 AM US - Pacific; decision 027). - -## Findings - -Sample week, gta.wiki "Halloween 2026 (Week 3)", 2026-10-15 to 2026-10-21. Its headings, which -the schema can mirror: - -- `Job Updates`: Salvage Yard vehicles (BF Weevil, Grotti Itali RSX, Albany Fränken Stange), - Kortz Center paintings, list of returning seasonal events. -- `Bonuses`: one-offs (complete "Don't Fuck With Dre" this week: GTA$500,000 within 72 hours; - all Weekly Challenges through November 4: GTA$2,000,000; free T-shirt), `Weekly Challenge` - (win 3 races: GTA$100,000), `FIB Priority File` (The Brute Force File), `Standard Bonuses` - (podium LSCM Cheetah Classic, Prize Ride Kanjo SJ, Test Rides, showrooms, Gun Van stock, - then lines like "3x GTA$ and RP from Slasher" and "2x GTA$ and RP from Dispatch Work"). -- `Discounts`: vehicles ("Albany Brigham – 40% off", some with a trade price too), properties - ("Agencies including upgrades and modifications – 40% off"), Gun Van weapons (FREE, 30% off). -- `Races and Time Trials`: Premium Race, Time Trial, HSW Time Trial (keep); Junk Energy and RC - Time Trials per day (drop). -- The Enhanced-only section (HSW) is the only console split on the page; LSOS ignores Legacy - (CLAUDE.md non-goals), so HSW items can be kept without a platform flag. -- A multiplier names an activity; some match owned things (Bail Office bounties, business - sales, Business Battles). The Breaking News bar should rank those first; how to match an - activity to a property id is still to design (perhaps an optional `property` id per entry). - -## Next steps - -1. Schema in `src/lib/weekly.ts`: types plus a validator (pure TS, unit-tested) that rejects a - malformed file; staleness from `start`/`end` and an injected `now`. -2. A first `public/weekly.json` for 2026-10-15 to 2026-10-22 from the sample page. -3. Weekly tab: grouped sections in the page's order (multipliers, challenge, gifts, rotations, - discounts), then a less prominent "Also running" list (collapsed or muted) at the bottom. -4. Breaking News bar on every tab: multipliers and gifts, owned matches first; stale notice - when `now >= end`. -5. Contributor section on writing a week by hand (in the PR or `docs/`). -6. `npm run verify`, then the `finish-pr` skill. - -## Open questions - -- Exact reset time is not officially sourced; check once against a real reset. 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=<title>&prop=wikitext&format=json`. +2. **Week.** `title` is the page title, `source` its URL (`https://gta.wiki/w/<Title_with_underscores>`), + `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: <title>`, linking the wiki page. diff --git a/src/App.svelte b/src/App.svelte index a26b409..1b3650e 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -8,11 +8,14 @@ import SaveStatus from './components/SaveStatus.svelte' import Settings from './components/Settings.svelte' import Timers from './components/Timers.svelte' + import NewsBar from './components/NewsBar.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' @@ -66,6 +69,7 @@ <div class="window"> <ClockBar title={kit ? 'Component kit' : TABS.find((tab) => tab.id === active)!.label} /> <SaveStatus /> + <NewsBar week={WEEK} owned={store.state.properties} now={clocks.now} /> <main> {#if kit && kitModule} @@ -125,6 +129,8 @@ order={store.state.timerOrder} onorder={(order) => (store.state.timerOrder = order)} /> + {:else if active === 'weekly'} + <Weekly week={WEEK} owned={store.state.properties} now={clocks.now} /> {:else if active === 'settings'} <Settings /> {:else} diff --git a/src/components/NewsBar.svelte b/src/components/NewsBar.svelte new file mode 100644 index 0000000..06f1651 --- /dev/null +++ b/src/components/NewsBar.svelte @@ -0,0 +1,132 @@ +<script lang="ts"> + // The Breaking News bar on every tab (issue #16): this week's bonus payouts and gifts scroll by, + // the ones paid through an owned property first. Says so when the week's data is out of date + // instead of showing old bonuses as current. Links to the Weekly tab. Logic: lib/weekly.ts. + import type { OwnedProperties } from '../lib/properties' + import { hashForTab } from '../lib/tabs' + import { newsItems, weekStatus, type Week } from '../lib/weekly' + + interface Props { + week: Week + owned: OwnedProperties + /** Wall-clock time. */ + now: number + } + + let { week, owned, now }: Props = $props() + + const status = $derived(weekStatus(week, now)) + const items = $derived(newsItems(week, owned)) + // Seconds per item, so a long week does not scroll faster than a short one. + const duration = $derived(Math.max(20, items.length * 6)) +</script> + +<a class="news" href={hashForTab('weekly')} aria-label="This week's events, open the Weekly tab"> + <span class="label">Breaking news</span> + {#if status === 'ended'} + <span class="stale">Weekly events out of date: the new week is not in yet</span> + {:else if status === 'upcoming' || items.length === 0} + <span class="quiet">No weekly events yet</span> + {:else} + <span class="window"> + <span class="track" style:animation-duration="{duration}s"> + {#each [0, 1] as copy (copy)} + <span class="copy" aria-hidden={copy === 1}> + {#each items as item (item.text)} + <span class="item" class:owned={item.owned}>{item.text}</span> + {/each} + </span> + {/each} + </span> + </span> + {/if} +</a> + +<style> + .news { + align-items: center; + background: var(--panel); + border-bottom: 1px solid var(--line); + color: var(--fg); + display: flex; + font-size: var(--text-sm); + gap: var(--space-3); + min-width: 0; + overflow: hidden; + padding: var(--space-1) var(--space-3); + text-decoration: none; + } + + .news:hover .track { + animation-play-state: paused; + } + + .label { + background: var(--accent-deep); + border-radius: var(--radius); + color: var(--accent-text); + flex-shrink: 0; + font-family: var(--font-display); + font-size: var(--text-xs); + letter-spacing: var(--tracking-wide); + padding: 0 var(--space-2); + text-transform: uppercase; + } + + .stale { + color: var(--warn); + } + + .quiet { + color: var(--muted); + } + + .window { + flex: 1; + min-width: 0; + overflow: hidden; + white-space: nowrap; + } + + .track { + animation: ticker linear infinite; + display: inline-flex; + } + + .copy { + display: inline-flex; + } + + .item { + padding-right: var(--space-6); + } + + .item::before { + color: var(--accent-text); + content: '■ '; + } + + .owned { + color: var(--accent-text); + } + + @keyframes ticker { + to { + transform: translateX(-50%); + } + } + + @media (prefers-reduced-motion: reduce) { + .track { + animation: none; + } + + .window { + text-overflow: ellipsis; + } + + .copy[aria-hidden='true'] { + display: none; + } + } +</style> diff --git a/src/components/Weekly.svelte b/src/components/Weekly.svelte new file mode 100644 index 0000000..62fabb2 --- /dev/null +++ b/src/components/Weekly.svelte @@ -0,0 +1,247 @@ +<script lang="ts"> + // The Weekly tab (issue #16, decision 027): this week's bonuses, challenge, gifts, rotations and + // discounts from `src/data/weekly.ts`, then the seasonal activities still running, kept quieter. + // Logic: lib/weekly.ts. + import { isOwned, type OwnedProperties } from '../lib/properties' + import { + discountText, + multiplierText, + weekStatus, + type Discount, + type Multiplier, + type Week, + } from '../lib/weekly' + import Fact from './ui/Fact.svelte' + import Panel from './ui/Panel.svelte' + + interface Props { + week: Week + owned: OwnedProperties + /** Wall-clock time. */ + now: number + } + + let { week, owned, now }: Props = $props() + + const status = $derived(weekStatus(week, now)) + const multipliers = $derived( + [...week.multipliers].sort((a, b) => Number(ownsIt(b)) - Number(ownsIt(a))), + ) + const DISCOUNT_GROUPS: { kind: Discount['kind']; label: string }[] = [ + { kind: 'vehicle', label: 'Vehicles' }, + { kind: 'property', label: 'Properties' }, + { kind: 'weapon', label: 'Gun Van' }, + ] + const DATE = new Intl.DateTimeFormat(undefined, { + weekday: 'short', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) + + function ownsIt(multiplier: Multiplier): boolean { + return multiplier.property !== undefined && isOwned(owned, multiplier.property) + } +</script> + +<div class="weekly"> + <header> + <h2>{week.title}</h2> + <p class="dates"> + {DATE.format(Date.parse(week.start))} to {DATE.format(Date.parse(week.end))} + <span class="links"> + <a href={week.source} target="_blank" rel="noopener">gta.wiki</a> + {#if week.newswire} + <a href={week.newswire} target="_blank" rel="noopener">Newswire</a> + {/if} + </span> + </p> + {#if status === 'ended'} + <p class="stale" role="status"> + Out of date: this week has ended and the new one is not in yet. These bonuses may no longer + apply. + </p> + {:else if status === 'upcoming'} + <p class="stale" role="status">This week has not started yet.</p> + {/if} + </header> + + <div class="grid" class:ended={status === 'ended'}> + <Panel title="Bonus payouts"> + <ul> + {#each multipliers as multiplier (multiplier.activity)} + <li class:owned={ownsIt(multiplier)}> + {multiplierText(multiplier)} + {#if ownsIt(multiplier)}<span class="tag">Owned</span>{/if} + </li> + {:else} + <li class="quiet">None this week.</li> + {/each} + </ul> + </Panel> + + <Panel title="Challenge and gifts"> + {#if week.challenge} + <Fact label="Weekly Challenge">{week.challenge.task}</Fact> + <Fact label="Reward">{week.challenge.reward}</Fact> + {/if} + <ul class="gifts"> + {#each week.gifts as gift (gift)} + <li>{gift}</li> + {/each} + </ul> + </Panel> + + <Panel title="This week's rotation"> + {#each week.rotations as rotation (rotation.label)} + <Fact label={rotation.label} title={rotation.note}> + {rotation.items.join(', ')} + {#if rotation.note}<span class="note">{rotation.note}</span>{/if} + </Fact> + {/each} + </Panel> + + <Panel title="Discounts"> + {#each DISCOUNT_GROUPS as group (group.kind)} + {@const items = week.discounts.filter((discount) => discount.kind === group.kind)} + {#if items.length > 0} + <h3>{group.label}</h3> + {#each items as discount (discount.item)} + <Fact label={discount.item}>{discountText(discount)}</Fact> + {/each} + {/if} + {/each} + </Panel> + </div> + + {#if week.alsoRunning.length > 0} + <details class="also"> + <summary>Also running, no bonus this week ({week.alsoRunning.length})</summary> + <p>{week.alsoRunning.join(', ')}</p> + </details> + {/if} +</div> + +<style> + .weekly { + display: flex; + flex-direction: column; + gap: var(--space-4); + } + + header h2 { + font-size: var(--text-lg); + letter-spacing: var(--tracking-wide); + margin: 0; + } + + .dates { + color: var(--muted); + display: flex; + flex-wrap: wrap; + font-size: var(--text-sm); + gap: var(--space-3); + margin: var(--space-1) 0 0; + } + + .links { + display: inline-flex; + gap: var(--space-3); + } + + a { + color: var(--accent-text); + } + + .stale { + border: 1px solid var(--warn); + border-radius: var(--radius); + color: var(--warn); + margin: var(--space-3) 0 0; + padding: var(--space-2) var(--space-3); + } + + .grid { + display: grid; + gap: var(--space-4); + align-items: start; + grid-template-columns: repeat(auto-fill, minmax(min(100%, 22rem), 1fr)); + } + + .grid.ended { + opacity: 0.6; + } + + ul { + display: flex; + flex-direction: column; + gap: var(--space-1); + list-style: none; + margin: 0; + padding: 0; + } + + .gifts { + margin-top: var(--space-3); + } + + .gifts li::before { + color: var(--accent-text); + content: '+ '; + } + + .owned { + color: var(--accent-text); + } + + .tag { + border: 1px solid var(--accent); + border-radius: var(--radius); + font-family: var(--font-display); + font-size: var(--text-xs); + letter-spacing: var(--tracking); + margin-left: var(--space-2); + padding: 0 var(--space-1); + text-transform: uppercase; + } + + .quiet, + .note { + color: var(--muted); + } + + .note { + display: block; + font-size: var(--text-xs); + } + + h3 { + color: var(--muted); + font-family: var(--font-display); + font-size: var(--text-xs); + letter-spacing: var(--tracking-wide); + margin: var(--space-3) 0 var(--space-1); + text-transform: uppercase; + } + + h3:first-child { + margin-top: 0; + } + + .also { + color: var(--muted); + font-size: var(--text-sm); + } + + .also summary { + cursor: pointer; + font-family: var(--font-display); + letter-spacing: var(--tracking); + text-transform: uppercase; + } + + .also p { + margin: var(--space-2) 0 0; + } +</style> 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..f3dde5e --- /dev/null +++ b/src/lib/weekly.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest' +import { PROPERTIES } from '../data/properties' +import { WEEK } from '../data/weekly' +import { setOwned } from './properties' +import { discountText, newsItems, weekProblems, weekStatus, type Week } from './weekly' + +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('newsItems', () => { + it('puts the highest multipliers first, then the gifts', () => { + expect(newsItems(SAMPLE, {}).map((item) => item.text)).toEqual([ + '3x GTA$ and RP: Slasher', + '2x GTA$ and RP: Dispatch Work', + '2x GTA$: Bounties', + 'Free T-shirt', + ]) + }) + + it('puts multipliers on owned properties ahead of everything', () => { + const owned = setOwned({}, 'bail-office', true) + expect(newsItems(SAMPLE, owned)[0]).toEqual({ text: '2x GTA$: Bounties', owned: true }) + }) +}) + +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('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..234ba07 --- /dev/null +++ b/src/lib/weekly.ts @@ -0,0 +1,111 @@ +// 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<Week, 'start' | 'end'>, now: number): WeekStatus { + if (now < Date.parse(week.start)) return 'upcoming' + return now < Date.parse(week.end) ? 'current' : 'ended' +} + +/** "3x GTA$ and RP: Slasher" */ +export function multiplierText(multiplier: Multiplier): string { + return `${multiplier.factor}x ${multiplier.pays}: ${multiplier.activity}` +} + +/** "Free" or "40% off" */ +export function discountText(discount: Discount): string { + return discount.percent >= 100 ? 'Free' : `${discount.percent}% off` +} + +/** + * The Breaking News lines: multipliers, highest first, with the ones paid through an owned + * property ahead of the rest; then the gifts. + */ +export function newsItems(week: Week, owned: OwnedProperties): { text: string; owned: boolean }[] { + const multipliers = week.multipliers + .map((multiplier) => ({ + text: multiplierText(multiplier), + owned: multiplier.property !== undefined && isOwned(owned, multiplier.property), + factor: multiplier.factor, + })) + .sort((a, b) => Number(b.owned) - Number(a.owned) || b.factor - a.factor) + .map(({ text, owned }) => ({ text, owned })) + return [...multipliers, ...week.gifts.map((text) => ({ text, owned: false }))] +} + +/** 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 +} From 1525be51c4e66afba69adb409f29719b5f938cbf Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 15 Oct 2026 16:54:04 +0000 Subject: [PATCH 4/4] Weekly: drop the Breaking News bar, tidy the Weekly tab (#16) The ticker on every tab was more distracting than helpful; the weekly events now live on the Weekly tab only. The tab gets a header with dates, a status pill (live with time left, or out of date) and the sources; bonus payouts as factor badges next to the challenge and gifts; rotation and discounts as labelled blocks of chips, discounts grouped by kind and percentage. An ended week stays readable, greyed under a warning. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- CLAUDE.md | 2 +- README.md | 2 +- docs/decisions/027-weekly-contents.md | 21 +- docs/decisions/README.md | 2 +- src/App.svelte | 2 - src/components/NewsBar.svelte | 132 -------- src/components/Weekly.svelte | 415 +++++++++++++++++++------- src/lib/weekly.test.ts | 55 +++- src/lib/weekly.ts | 47 +-- 9 files changed, 392 insertions(+), 286 deletions(-) delete mode 100644 src/components/NewsBar.svelte diff --git a/CLAUDE.md b/CLAUDE.md index a37dfd2..42a832a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,7 +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, Weekly tab, Breaking News bar | `data/weekly.ts`, `lib/weekly.ts` (decision 027) | +| 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 bb911b7..6112fec 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ emptying. - **Resync anytime.** When an estimate drifts from what the game shows, click the value and type the real one. - **Weekly events** (bonuses, discounts, rotations) come from a data file in this repo that's updated - weekly, copied from gta.wiki. A Breaking News bar shows the bonuses on every tab. + 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 index 5af5896..51a66a4 100644 --- a/docs/decisions/027-weekly-contents.md +++ b/docs/decisions/027-weekly-contents.md @@ -24,8 +24,7 @@ (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 (e.g. a collapsed or muted "Also running" list), and never in the - Breaking News bar. + 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 @@ -33,14 +32,18 @@ - **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 (bonus payouts with owned ones first, challenge and gifts, - rotation, discounts by vehicles, properties and Gun Van; then a collapsed "Also running" list) - and in a Breaking News bar on every tab (multipliers, then gifts; links to the Weekly tab). - The Weekly panels keep a fixed order: unlike the business cards, there is nothing to compare - between them. A multiplier names its `property` id when it pays through one (e.g. bounties +- **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 bar says the weekly events are out of date and the tab - shows a warning over dimmed panels, instead of old bonuses as current. +- **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 diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 798046c..a1c0684 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -37,4 +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 and Breaking News bar; 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 +- [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/src/App.svelte b/src/App.svelte index 1b3650e..2cadefb 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -8,7 +8,6 @@ import SaveStatus from './components/SaveStatus.svelte' import Settings from './components/Settings.svelte' import Timers from './components/Timers.svelte' - import NewsBar from './components/NewsBar.svelte' import Weekly from './components/Weekly.svelte' import TabBar from './components/ui/TabBar.svelte' import { GOODS } from './data/nightclub' @@ -69,7 +68,6 @@ <div class="window"> <ClockBar title={kit ? 'Component kit' : TABS.find((tab) => tab.id === active)!.label} /> <SaveStatus /> - <NewsBar week={WEEK} owned={store.state.properties} now={clocks.now} /> <main> {#if kit && kitModule} diff --git a/src/components/NewsBar.svelte b/src/components/NewsBar.svelte deleted file mode 100644 index 06f1651..0000000 --- a/src/components/NewsBar.svelte +++ /dev/null @@ -1,132 +0,0 @@ -<script lang="ts"> - // The Breaking News bar on every tab (issue #16): this week's bonus payouts and gifts scroll by, - // the ones paid through an owned property first. Says so when the week's data is out of date - // instead of showing old bonuses as current. Links to the Weekly tab. Logic: lib/weekly.ts. - import type { OwnedProperties } from '../lib/properties' - import { hashForTab } from '../lib/tabs' - import { newsItems, weekStatus, type Week } from '../lib/weekly' - - interface Props { - week: Week - owned: OwnedProperties - /** Wall-clock time. */ - now: number - } - - let { week, owned, now }: Props = $props() - - const status = $derived(weekStatus(week, now)) - const items = $derived(newsItems(week, owned)) - // Seconds per item, so a long week does not scroll faster than a short one. - const duration = $derived(Math.max(20, items.length * 6)) -</script> - -<a class="news" href={hashForTab('weekly')} aria-label="This week's events, open the Weekly tab"> - <span class="label">Breaking news</span> - {#if status === 'ended'} - <span class="stale">Weekly events out of date: the new week is not in yet</span> - {:else if status === 'upcoming' || items.length === 0} - <span class="quiet">No weekly events yet</span> - {:else} - <span class="window"> - <span class="track" style:animation-duration="{duration}s"> - {#each [0, 1] as copy (copy)} - <span class="copy" aria-hidden={copy === 1}> - {#each items as item (item.text)} - <span class="item" class:owned={item.owned}>{item.text}</span> - {/each} - </span> - {/each} - </span> - </span> - {/if} -</a> - -<style> - .news { - align-items: center; - background: var(--panel); - border-bottom: 1px solid var(--line); - color: var(--fg); - display: flex; - font-size: var(--text-sm); - gap: var(--space-3); - min-width: 0; - overflow: hidden; - padding: var(--space-1) var(--space-3); - text-decoration: none; - } - - .news:hover .track { - animation-play-state: paused; - } - - .label { - background: var(--accent-deep); - border-radius: var(--radius); - color: var(--accent-text); - flex-shrink: 0; - font-family: var(--font-display); - font-size: var(--text-xs); - letter-spacing: var(--tracking-wide); - padding: 0 var(--space-2); - text-transform: uppercase; - } - - .stale { - color: var(--warn); - } - - .quiet { - color: var(--muted); - } - - .window { - flex: 1; - min-width: 0; - overflow: hidden; - white-space: nowrap; - } - - .track { - animation: ticker linear infinite; - display: inline-flex; - } - - .copy { - display: inline-flex; - } - - .item { - padding-right: var(--space-6); - } - - .item::before { - color: var(--accent-text); - content: '■ '; - } - - .owned { - color: var(--accent-text); - } - - @keyframes ticker { - to { - transform: translateX(-50%); - } - } - - @media (prefers-reduced-motion: reduce) { - .track { - animation: none; - } - - .window { - text-overflow: ellipsis; - } - - .copy[aria-hidden='true'] { - display: none; - } - } -</style> diff --git a/src/components/Weekly.svelte b/src/components/Weekly.svelte index 62fabb2..c8b67cc 100644 --- a/src/components/Weekly.svelte +++ b/src/components/Weekly.svelte @@ -1,17 +1,17 @@ <script lang="ts"> - // The Weekly tab (issue #16, decision 027): this week's bonuses, challenge, gifts, rotations and + // The Weekly tab (issue #16, decision 027): this week's bonuses, challenge, gifts, rotation and // discounts from `src/data/weekly.ts`, then the seasonal activities still running, kept quieter. // Logic: lib/weekly.ts. import { isOwned, type OwnedProperties } from '../lib/properties' import { discountText, - multiplierText, + discountTiers, + sortMultipliers, + timeLeftText, weekStatus, type Discount, - type Multiplier, type Week, } from '../lib/weekly' - import Fact from './ui/Fact.svelte' import Panel from './ui/Panel.svelte' interface Props { @@ -24,113 +24,169 @@ let { week, owned, now }: Props = $props() const status = $derived(weekStatus(week, now)) - const multipliers = $derived( - [...week.multipliers].sort((a, b) => Number(ownsIt(b)) - Number(ownsIt(a))), - ) - const DISCOUNT_GROUPS: { kind: Discount['kind']; label: string }[] = [ + const multipliers = $derived(sortMultipliers(week.multipliers, owned)) + const KINDS: { kind: Discount['kind']; label: string }[] = [ { kind: 'vehicle', label: 'Vehicles' }, { kind: 'property', label: 'Properties' }, { kind: 'weapon', label: 'Gun Van' }, ] const DATE = new Intl.DateTimeFormat(undefined, { weekday: 'short', - month: 'short', day: 'numeric', + month: 'short', hour: '2-digit', minute: '2-digit', }) - - function ownsIt(multiplier: Multiplier): boolean { - return multiplier.property !== undefined && isOwned(owned, multiplier.property) - } </script> <div class="weekly"> - <header> - <h2>{week.title}</h2> - <p class="dates"> - {DATE.format(Date.parse(week.start))} to {DATE.format(Date.parse(week.end))} - <span class="links"> - <a href={week.source} target="_blank" rel="noopener">gta.wiki</a> - {#if week.newswire} - <a href={week.newswire} target="_blank" rel="noopener">Newswire</a> - {/if} - </span> - </p> - {#if status === 'ended'} - <p class="stale" role="status"> - Out of date: this week has ended and the new one is not in yet. These bonuses may no longer - apply. + <header class="head"> + <div> + <h2>{week.title}</h2> + <p class="dates"> + {DATE.format(Date.parse(week.start))} – {DATE.format(Date.parse(week.end))} </p> - {:else if status === 'upcoming'} - <p class="stale" role="status">This week has not started yet.</p> - {/if} + </div> + <div class="meta"> + {#if status === 'current'} + <span class="pill live">Live · {timeLeftText(Date.parse(week.end) - now)}</span> + {:else if status === 'ended'} + <span class="pill stale">Out of date</span> + {:else} + <span class="pill">Starts soon</span> + {/if} + <a href={week.source} target="_blank" rel="noopener">gta.wiki</a> + {#if week.newswire} + <a href={week.newswire} target="_blank" rel="noopener">Newswire</a> + {/if} + </div> </header> - <div class="grid" class:ended={status === 'ended'}> - <Panel title="Bonus payouts"> - <ul> - {#each multipliers as multiplier (multiplier.activity)} - <li class:owned={ownsIt(multiplier)}> - {multiplierText(multiplier)} - {#if ownsIt(multiplier)}<span class="tag">Owned</span>{/if} - </li> - {:else} - <li class="quiet">None this week.</li> - {/each} - </ul> - </Panel> + {#if status === 'ended'} + <p class="warning" role="status"> + This week has ended and the new one is not in yet. The events below are last week's and may no + longer apply. + </p> + {/if} - <Panel title="Challenge and gifts"> - {#if week.challenge} - <Fact label="Weekly Challenge">{week.challenge.task}</Fact> - <Fact label="Reward">{week.challenge.reward}</Fact> - {/if} - <ul class="gifts"> - {#each week.gifts as gift (gift)} - <li>{gift}</li> - {/each} - </ul> - </Panel> + <div class="layout" class:ended={status === 'ended'}> + <div class="top"> + <Panel title="Bonus payouts"> + <ul class="bonuses"> + {#each multipliers as multiplier (multiplier.activity)} + {@const mine = multiplier.property !== undefined && isOwned(owned, multiplier.property)} + <li class:mine> + <span class="factor" class:high={multiplier.factor >= 3}>{multiplier.factor}x</span> + <span class="activity"> + {multiplier.activity} + {#if mine}<span class="owned-tag">Owned</span>{/if} + </span> + <span class="pays">{multiplier.pays}</span> + </li> + {:else} + <li class="quiet">No bonus payouts this week.</li> + {/each} + </ul> + </Panel> + + <Panel title="Challenge and gifts"> + {#if week.challenge} + <div class="challenge"> + <span class="kicker">Weekly Challenge</span> + <strong>{week.challenge.task}</strong> + <span class="reward">{week.challenge.reward}</span> + </div> + {/if} + {#if week.gifts.length > 0} + <ul class="gifts"> + {#each week.gifts as gift (gift)} + <li>{gift}</li> + {/each} + </ul> + {/if} + </Panel> + </div> <Panel title="This week's rotation"> - {#each week.rotations as rotation (rotation.label)} - <Fact label={rotation.label} title={rotation.note}> - {rotation.items.join(', ')} - {#if rotation.note}<span class="note">{rotation.note}</span>{/if} - </Fact> - {/each} + <div class="blocks"> + {#each week.rotations as rotation (rotation.label)} + <section class="block"> + <h3>{rotation.label}</h3> + <ul class="chips"> + {#each rotation.items as item (item)} + <li>{item}</li> + {/each} + </ul> + {#if rotation.note}<p class="note">{rotation.note}</p>{/if} + </section> + {/each} + </div> </Panel> <Panel title="Discounts"> - {#each DISCOUNT_GROUPS as group (group.kind)} - {@const items = week.discounts.filter((discount) => discount.kind === group.kind)} - {#if items.length > 0} - <h3>{group.label}</h3> - {#each items as discount (discount.item)} - <Fact label={discount.item}>{discountText(discount)}</Fact> - {/each} - {/if} - {/each} + <div class="blocks discounts"> + {#each KINDS as { kind, label } (kind)} + {@const tiers = discountTiers(week.discounts, kind)} + {#if tiers.length > 0} + <section class="block"> + <h3>{label}</h3> + {#each tiers as tier (tier.percent)} + <div class="tier"> + <span class="off" class:free={tier.percent >= 100}> + {discountText({ kind, item: '', percent: tier.percent })} + </span> + <ul class="chips"> + {#each tier.items as item (item)} + <li>{item}</li> + {/each} + </ul> + </div> + {/each} + </section> + {/if} + {/each} + </div> </Panel> </div> {#if week.alsoRunning.length > 0} <details class="also"> <summary>Also running, no bonus this week ({week.alsoRunning.length})</summary> - <p>{week.alsoRunning.join(', ')}</p> + <ul class="chips"> + {#each week.alsoRunning as item (item)} + <li>{item}</li> + {/each} + </ul> </details> {/if} </div> <style> - .weekly { + .weekly, + .layout { display: flex; flex-direction: column; gap: var(--space-4); } - header h2 { + ul { + list-style: none; + margin: 0; + padding: 0; + } + + /* Header: title and dates on the left, status and sources on the right. */ + .head { + align-items: flex-end; + border-bottom: 1px solid var(--line); + display: flex; + flex-wrap: wrap; + gap: var(--space-3); + justify-content: space-between; + padding-bottom: var(--space-3); + } + + h2 { font-size: var(--text-lg); letter-spacing: var(--tracking-wide); margin: 0; @@ -138,64 +194,102 @@ .dates { color: var(--muted); + font-size: var(--text-sm); + margin: var(--space-1) 0 0; + } + + .meta { + align-items: center; display: flex; flex-wrap: wrap; font-size: var(--text-sm); gap: var(--space-3); - margin: var(--space-1) 0 0; } - .links { - display: inline-flex; - gap: var(--space-3); + .meta a { + color: var(--accent-text); } - a { + .pill { + border: 1px solid var(--line); + border-radius: 999px; + color: var(--muted); + font-family: var(--font-display); + font-size: var(--text-xs); + letter-spacing: var(--tracking); + padding: 0.1rem var(--space-3); + text-transform: uppercase; + } + + .pill.live { + border-color: var(--accent); color: var(--accent-text); } - .stale { + .pill.stale { + border-color: var(--warn); + color: var(--warn); + } + + .warning { + background: color-mix(in srgb, var(--warn) 10%, transparent); border: 1px solid var(--warn); border-radius: var(--radius); color: var(--warn); - margin: var(--space-3) 0 0; + margin: 0; padding: var(--space-2) var(--space-3); } - .grid { + .layout.ended { + filter: grayscale(0.6); + opacity: 0.6; + } + + /* Bonus payouts (wide) next to challenge and gifts. */ + .top { + align-items: start; display: grid; gap: var(--space-4); - align-items: start; - grid-template-columns: repeat(auto-fill, minmax(min(100%, 22rem), 1fr)); + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); } - .grid.ended { - opacity: 0.6; + .bonuses li { + align-items: center; + border-bottom: 1px solid var(--line); + display: grid; + gap: var(--space-3); + grid-template-columns: 2.75rem 1fr auto; + padding: var(--space-2) 0; } - ul { - display: flex; - flex-direction: column; - gap: var(--space-1); - list-style: none; - margin: 0; - padding: 0; + .bonuses li:last-child { + border-bottom: none; } - .gifts { - margin-top: var(--space-3); + .factor { + border: 1px solid var(--accent); + border-radius: var(--radius); + color: var(--accent-text); + font-family: var(--font-display); + font-weight: 600; + text-align: center; } - .gifts li::before { - color: var(--accent-text); - content: '+ '; + .factor.high { + background: var(--accent-deep); + } + + .pays { + color: var(--muted); + font-size: var(--text-xs); + white-space: nowrap; } - .owned { + .mine .activity { color: var(--accent-text); } - .tag { + .owned-tag { border: 1px solid var(--accent); border-radius: var(--radius); font-family: var(--font-display); @@ -206,27 +300,115 @@ text-transform: uppercase; } - .quiet, - .note { + .challenge { + background: var(--panel-raised); + border-left: 3px solid var(--accent); + border-radius: var(--radius); + display: flex; + flex-direction: column; + gap: var(--space-1); + padding: var(--space-3); + } + + .kicker, + h3 { color: var(--muted); + font-family: var(--font-display); + font-size: var(--text-xs); + font-weight: normal; + letter-spacing: var(--tracking-wide); + margin: 0; + text-transform: uppercase; + } + + .challenge strong { + font-size: var(--text-md); + } + + .reward { + color: var(--accent-text); + font-size: var(--text-sm); + } + + .gifts { + display: flex; + flex-direction: column; + gap: var(--space-2); + margin-top: var(--space-3); + } + + .gifts li { + font-size: var(--text-sm); + padding-left: var(--space-4); + position: relative; + } + + .gifts li::before { + color: var(--accent-text); + content: '+'; + left: 0; + position: absolute; + } + + /* Rotation and discounts: labelled blocks of chips in an even grid. */ + .blocks { + display: grid; + gap: var(--space-4) var(--space-5); + grid-template-columns: repeat(auto-fill, minmax(min(100%, 16rem), 1fr)); + } + + .discounts { + grid-template-columns: minmax(0, 2fr) minmax(0, 1fr) minmax(0, 1fr); + } + + .block { + display: flex; + flex-direction: column; + gap: var(--space-2); + min-width: 0; + } + + .chips { + display: flex; + flex-wrap: wrap; + gap: var(--space-1); + } + + .chips li { + background: var(--panel-raised); + border: 1px solid var(--line); + border-radius: var(--radius); + font-size: var(--text-sm); + padding: 0.1rem var(--space-2); } .note { - display: block; + color: var(--muted); font-size: var(--text-xs); + margin: 0; } - h3 { - color: var(--muted); + .tier { + align-items: baseline; + display: grid; + gap: var(--space-2); + grid-template-columns: 4.5rem 1fr; + } + + .off { + color: var(--accent-text); font-family: var(--font-display); - font-size: var(--text-xs); - letter-spacing: var(--tracking-wide); - margin: var(--space-3) 0 var(--space-1); + font-size: var(--text-sm); + letter-spacing: var(--tracking); text-transform: uppercase; } - h3:first-child { - margin-top: 0; + .off.free { + color: var(--warn); + } + + .quiet { + color: var(--muted); } .also { @@ -241,7 +423,18 @@ text-transform: uppercase; } - .also p { - margin: var(--space-2) 0 0; + .also .chips { + margin-top: var(--space-2); + } + + .also .chips li { + background: none; + } + + @media (max-width: 52rem) { + .top, + .discounts { + grid-template-columns: minmax(0, 1fr); + } } </style> diff --git a/src/lib/weekly.test.ts b/src/lib/weekly.test.ts index f3dde5e..ed4c6dc 100644 --- a/src/lib/weekly.test.ts +++ b/src/lib/weekly.test.ts @@ -2,8 +2,17 @@ import { describe, expect, it } from 'vitest' import { PROPERTIES } from '../data/properties' import { WEEK } from '../data/weekly' import { setOwned } from './properties' -import { discountText, newsItems, weekProblems, weekStatus, type Week } from './weekly' +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 = { @@ -37,19 +46,26 @@ describe('weekStatus', () => { }) }) -describe('newsItems', () => { - it('puts the highest multipliers first, then the gifts', () => { - expect(newsItems(SAMPLE, {}).map((item) => item.text)).toEqual([ - '3x GTA$ and RP: Slasher', - '2x GTA$ and RP: Dispatch Work', - '2x GTA$: Bounties', - 'Free T-shirt', +describe('sortMultipliers', () => { + it('puts the highest first', () => { + expect(sortMultipliers(SAMPLE.multipliers, {}).map((m) => m.activity)).toEqual([ + 'Slasher', + 'Dispatch Work', + 'Bounties', ]) }) - it('puts multipliers on owned properties ahead of everything', () => { + it('puts the ones paid through an owned property ahead of everything', () => { const owned = setOwned({}, 'bail-office', true) - expect(newsItems(SAMPLE, owned)[0]).toEqual({ text: '2x GTA$: Bounties', owned: 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') }) }) @@ -60,6 +76,25 @@ describe('discountText', () => { }) }) +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 = { diff --git a/src/lib/weekly.ts b/src/lib/weekly.ts index 234ba07..59366c7 100644 --- a/src/lib/weekly.ts +++ b/src/lib/weekly.ts @@ -61,30 +61,39 @@ export function weekStatus(week: Pick<Week, 'start' | 'end'>, now: number): Week return now < Date.parse(week.end) ? 'current' : 'ended' } -/** "3x GTA$ and RP: Slasher" */ -export function multiplierText(multiplier: Multiplier): string { - return `${multiplier.factor}x ${multiplier.pays}: ${multiplier.activity}` -} - /** "Free" or "40% off" */ export function discountText(discount: Discount): string { return discount.percent >= 100 ? 'Free' : `${discount.percent}% off` } -/** - * The Breaking News lines: multipliers, highest first, with the ones paid through an owned - * property ahead of the rest; then the gifts. - */ -export function newsItems(week: Week, owned: OwnedProperties): { text: string; owned: boolean }[] { - const multipliers = week.multipliers - .map((multiplier) => ({ - text: multiplierText(multiplier), - owned: multiplier.property !== undefined && isOwned(owned, multiplier.property), - factor: multiplier.factor, - })) - .sort((a, b) => Number(b.owned) - Number(a.owned) || b.factor - a.factor) - .map(({ text, owned }) => ({ text, owned })) - return [...multipliers, ...week.gifts.map((text) => ({ text, owned: false }))] +/** 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<number, string[]>() + 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. */