From 49a37293557644497006cebd7cbbb7b51980d875 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Oliva?= Date: Thu, 8 Dec 2022 11:25:32 +0100 Subject: [PATCH 1/2] feat: manually insert season/player details --- src/App/CreateGoal.tsx | 14 +- src/App/Header.tsx | 5 +- src/App/PlayerDetails.css | 3 + src/App/PlayerDetails.tsx | 87 +++++++++++- src/App/Result.tsx | 27 +--- src/App/playerDetailsState.ts | 241 +++++++++++++++++++++++++++++----- src/App/season.ts | 21 ++- src/service/gw2Api.ts | 41 +++--- src/service/localData.ts | 21 +-- src/service/rewards.ts | 15 ++- 10 files changed, 370 insertions(+), 105 deletions(-) create mode 100644 src/App/PlayerDetails.css diff --git a/src/App/CreateGoal.tsx b/src/App/CreateGoal.tsx index b5cc1fe..c5c70ed 100644 --- a/src/App/CreateGoal.tsx +++ b/src/App/CreateGoal.tsx @@ -2,11 +2,11 @@ import { state, useStateObservable } from "@react-rxjs/core"; import { createSignal } from "@react-rxjs/utils"; import { useMemo } from "react"; import { combineLatest, map, of, startWith, switchMap } from "rxjs"; -import { SeasonData } from "../service/localData"; +import { SeasonDetails } from "../service/localData"; import { getRewardForGoal } from "../service/rewards"; import "./CreateGoal.css"; import { createGoal } from "./goals"; -import { currentSeason$ } from "./season"; +import { selectedSeason$ } from "./playerDetailsState"; const [nameChange$, setName] = createSignal(); const name$ = state(nameChange$, ""); @@ -16,7 +16,7 @@ const divisions$ = state(divisionsChange$, 1); // TODO minimum depends on curren const [repeatsChange$, setRepeats] = createSignal(); const repeats$ = state( - combineLatest([currentSeason$, divisionsChange$]).pipe( + combineLatest([selectedSeason$, divisionsChange$]).pipe( switchMap(([season, divisions]) => divisions === season?.divisions.length ? repeatsChange$.pipe( @@ -39,7 +39,7 @@ const repeats$ = state( ); export function CreateGoal({ onClose }: { onClose: () => void }) { - const season = useStateObservable(currentSeason$); + const season = useStateObservable(selectedSeason$); const name = useStateObservable(name$); const divisions = useStateObservable(divisions$); const repeats = useStateObservable(repeats$); @@ -145,7 +145,11 @@ export function CreateGoal({ onClose }: { onClose: () => void }) { ); } -function calculateCost(season: SeasonData, divisions: number, repeats: number) { +function calculateCost( + season: SeasonDetails, + divisions: number, + repeats: number +) { let result = 0; for (let i = 0; i < divisions; i++) { result += season.divisions[i].pips; diff --git a/src/App/Header.tsx b/src/App/Header.tsx index 531bc73..115145d 100644 --- a/src/App/Header.tsx +++ b/src/App/Header.tsx @@ -1,15 +1,16 @@ import { useStateObservable } from "@react-rxjs/core"; import { appWindow } from "@tauri-apps/api/window"; -import { currentSeason$ } from "./season"; +import { currentSeason$, seasonIsActive } from "./season"; import "./Header.css"; function Header() { const season = useStateObservable(currentSeason$); + const isActive = seasonIsActive(season); return (
- {season ? season.name : "PvP Pips Calculator"} + {season && isActive ? season.name : "PvP Pips Calculator"}
appWindow.minimize()}> diff --git a/src/App/PlayerDetails.css b/src/App/PlayerDetails.css new file mode 100644 index 0000000..899eb75 --- /dev/null +++ b/src/App/PlayerDetails.css @@ -0,0 +1,3 @@ +.season-pick { + margin: 0 0.3rem; +} diff --git a/src/App/PlayerDetails.tsx b/src/App/PlayerDetails.tsx index 3140e9b..b1d8063 100644 --- a/src/App/PlayerDetails.tsx +++ b/src/App/PlayerDetails.tsx @@ -5,17 +5,22 @@ import { useEffect, useState } from "react"; import { Pip } from "../components/Pip"; import { apiKey$, + endDate$, playerDetails$, refresh, + selectedType$, + selectType, setApiKey, + setEndDate, + setPips, } from "./playerDetailsState"; -import { currentSeason$ } from "./season"; +import { currentSeasonIsActive$ } from "./season"; +import "./PlayerDetails.css"; TimeAgo.addDefaultLocale(en); const timeAgo = new TimeAgo("en"); -function PlayerDetails() { - const season = useStateObservable(currentSeason$); +function APIPlayerDetails() { const apiKey = useStateObservable(apiKey$); const playerDetails = useStateObservable(playerDetails$); @@ -27,7 +32,7 @@ function PlayerDetails() { value={apiKey} onChange={(evt) => setApiKey(evt.target.value)} /> -
@@ -41,6 +46,80 @@ function PlayerDetails() {
); } +function ManualPlayerDetails() { + const details = useStateObservable(playerDetails$); + const selectedType = useStateObservable(selectedType$); + const endDate = useStateObservable(endDate$); + console.log("endDate", endDate); + + return ( +
+
⚠️ The API didn't return an active season yet ⚠️
+
+ + setPips(Number(evt.target.value))} + /> +
+
+ + + +
+
+ Season end: + +
+
+ ); +} + +function PlayerDetails() { + const isActive = useStateObservable(currentSeasonIsActive$); + + if (!isActive == null) return null; + + if (isActive) { + return ; + } + return ; +} function RefreshingTimeAgo({ date }: { date?: Date }) { const [formattedTime, setFormattedTime] = useState( diff --git a/src/App/Result.tsx b/src/App/Result.tsx index 2fd2276..647893f 100644 --- a/src/App/Result.tsx +++ b/src/App/Result.tsx @@ -2,39 +2,26 @@ import { state, useStateObservable } from "@react-rxjs/core"; import { Fragment, useState } from "react"; import { combineLatestWith, filter, map } from "rxjs"; import { Pip } from "../components/Pip"; -import { initialConfig$ } from "../service/localData"; import { CreateGoal } from "./CreateGoal"; import { deleteGoal, goals$ } from "./goals"; -import { playerDetails$ } from "./playerDetailsState"; +import { playerDetails$, selectedSeason$ } from "./playerDetailsState"; import "./Result.css"; -import { currentSeason$ } from "./season"; - -const holidays$ = state( - initialConfig$.pipe(map((config) => config.holidays)), - [] -); // TODO config -const POINTS_WIN = 11; -const POINTS_LOSE = 4; +const POINTS_WIN = 11 + 2; +const POINTS_LOSE = 4 + 2; const POINT_AVG = (POINTS_LOSE + POINTS_WIN) / 2; const result$ = state( playerDetails$.pipe( filter((v) => !!v), map((v) => v!), - combineLatestWith(holidays$, currentSeason$.pipe(filter((v) => !!v))), - map(([{ pips, timestamp }, holidays, season]) => { - const seasonStart = new Date(season!.start); + combineLatestWith(selectedSeason$.pipe(filter((v) => !!v))), + map(([{ pips, timestamp }, season]) => { const seasonEnd = new Date(season!.end); - const seasonDays = Math.round( - (seasonEnd.getTime() - seasonStart.getTime()) / (24 * 60 * 60_000) - ); - const currentDay = Math.floor( - (timestamp.getTime() - seasonStart.getTime()) / (24 * 60 * 60_000) + const remainingDays = Math.round( + (seasonEnd.getTime() - timestamp.getTime()) / (24 * 60 * 60_000) ); - const relevantHolidays = holidays.filter((h) => h >= currentDay).length; - const remainingDays = seasonDays - currentDay - relevantHolidays; return { pips, remainingDays, timestamp }; }), diff --git a/src/App/playerDetailsState.ts b/src/App/playerDetailsState.ts index 6ea39a4..443557d 100644 --- a/src/App/playerDetailsState.ts +++ b/src/App/playerDetailsState.ts @@ -1,14 +1,26 @@ import { state } from "@react-rxjs/core"; import { createSignal } from "@react-rxjs/utils"; -import { filter, map, merge, switchMap, tap, withLatestFrom } from "rxjs"; +import { + combineLatest, + concat, + filter, + map, + merge, + Observable, + switchMap, + tap, + withLatestFrom, +} from "rxjs"; import { getSeasonCurrentPips$ } from "../service/gw2Api"; import { cacheData$, + Division, initialConfig$, + SeasonDetails, writeCache$, writeConfig$, } from "../service/localData"; -import { currentSeason$ } from "./season"; +import { currentSeason$, currentSeasonIsActive$ } from "./season"; const [apiKeyChange$, setApiKey] = createSignal(); export const apiKey$ = state( @@ -17,43 +29,206 @@ export const apiKey$ = state( ); const [refresh$, refresh] = createSignal(); -export { refresh, setApiKey }; - -export const playerDetails$ = state( - merge( - // Cached result - cacheData$.pipe( - map((cache) => cache?.lastResult!), - filter((v) => !!v), - map((lastResult) => ({ - timestamp: new Date(lastResult.timestamp), - pips: lastResult.hadPips, - })) - ), - // New result - refresh$.pipe( - withLatestFrom(apiKey$, currentSeason$), - tap(([, apiKey]) => { - writeConfig$({ - apiKey, - }).subscribe(); - }), - switchMap(([, apiKey, season]) => - getSeasonCurrentPips$(apiKey, season!.id) - ), - map((pips) => ({ - timestamp: new Date(), - pips, - })), - tap((result) => { +const [pipsInput$, setPips] = createSignal(); +const [selectedTypeChange$, selectType] = createSignal<"2v2" | "3v3" | "5v5">(); +const [endDateChange$, setEndDate] = createSignal(); +export { refresh, setApiKey, setPips, selectType, setEndDate }; + +export interface PlayerDetails { + pips: number; + timestamp: Date; +} + +const cachedPlayerDetails$ = cacheData$.pipe( + map((cache) => cache?.lastResult!), + map((lastResult) => + lastResult + ? { + timestamp: new Date(lastResult.timestamp), + pips: lastResult.hadPips, + } + : null + ) +); +function cachePlayerDetails() { + return (source$: Observable) => + source$.pipe( + tap((result) => writeCache$({ lastResult: { timestamp: result.timestamp.toISOString(), hadPips: result.pips, }, - }).subscribe(); - }) + }).subscribe() + ) + ); +} + +const APIPlayerDetails$ = concat( + cachedPlayerDetails$.pipe( + filter((v) => !!v), + map((v) => v!) + ), + refresh$.pipe( + withLatestFrom(apiKey$, currentSeason$), + tap(([, apiKey]) => { + writeConfig$({ + apiKey, + }).subscribe(); + }), + switchMap(([, apiKey, season]) => + getSeasonCurrentPips$(apiKey, season!.id) + ), + map((pips) => ({ + timestamp: new Date(), + pips, + })), + cachePlayerDetails() + ) +); + +const manualPlayerDetails$ = concat( + cachedPlayerDetails$.pipe( + map((v) => + v + ? v + : { + timestamp: new Date(), + pips: 0, + } + ) + ), + pipsInput$.pipe( + map((pips) => ({ + pips, + timestamp: new Date(), + })), + cachePlayerDetails() + ) +); + +export const playerDetails$ = state( + currentSeasonIsActive$.pipe( + filter((v) => v !== null), + switchMap((isActive) => + isActive ? APIPlayerDetails$ : manualPlayerDetails$ ) ), null ); + +export const selectedType$ = state(selectedTypeChange$, "2v2"); +export const endDate$ = state( + endDateChange$.pipe(map((v) => (v ? v : null))), + null +); + +const manualSeason$: Observable = combineLatest({ + type: selectedType$, + divisions: selectedType$.pipe( + map((v) => + v === "2v2" + ? mini2v2Divisions + : v === "3v3" + ? mini3v3Divisions + : regularDivisions + ) + ), + end: endDate$.pipe( + filter((v) => !!v), + map((v) => v!) + ), +}); + +export const selectedSeason$ = state( + currentSeasonIsActive$.pipe( + filter((v) => v !== null), + switchMap((isActive) => (isActive ? currentSeason$ : manualSeason$)) + ), + null +); + +const regularDivisions: Array = [ + { + name: "Cerulean", + repeatable: false, + icon: "https://render.guildwars2.com/file/CBACFFCD30B623FCCAF3CC7296056265F15E09BB/1614868.png", + pips: 60, + }, + { + name: "Jasper", + repeatable: false, + icon: "https://render.guildwars2.com/file/769445B8AFC30D92345AB6A84ACD02A223B5B1B5/1614869.png", + pips: 80, + }, + { + name: "Saffron", + repeatable: false, + icon: "https://render.guildwars2.com/file/509921D3BFDC049BC20758B71AD85592A043A439/1614870.png", + pips: 100, + }, + { + name: "Persimmon", + repeatable: false, + icon: "https://render.guildwars2.com/file/5807B5E8BC4658DE9CB44664C125A6A3900D80A9/1614871.png", + pips: 100, + }, + { + name: "Amaranth", + repeatable: false, + icon: "https://render.guildwars2.com/file/9CEAC0D269EC685D2818320FACC4130151C9B4B7/1614872.png", + pips: 150, + }, + { + name: "Byzantium (Repeatable)", + repeatable: true, + icon: "https://render.guildwars2.com/file/52F72F4C72B517B0955D00CE0415E9B778191395/1614873.png", + pips: 180, + }, +]; +const mini3v3Divisions: Array = [ + { + name: "Cerulean", + repeatable: false, + icon: "https://render.guildwars2.com/file/CBACFFCD30B623FCCAF3CC7296056265F15E09BB/1614868.png", + pips: 60, + }, + { + name: "Jasper", + repeatable: false, + icon: "https://render.guildwars2.com/file/769445B8AFC30D92345AB6A84ACD02A223B5B1B5/1614869.png", + pips: 80, + }, + { + name: "Saffron (Repeatable)", + repeatable: true, + icon: "https://render.guildwars2.com/file/509921D3BFDC049BC20758B71AD85592A043A439/1614870.png", + pips: 100, + }, +]; +const mini2v2Divisions: Array = [ + { + name: "Cerulean", + repeatable: false, + icon: "https://render.guildwars2.com/file/CBACFFCD30B623FCCAF3CC7296056265F15E09BB/1614868.png", + pips: 60, + }, + { + name: "Jasper", + repeatable: false, + icon: "https://render.guildwars2.com/file/769445B8AFC30D92345AB6A84ACD02A223B5B1B5/1614869.png", + pips: 80, + }, + { + name: "Saffron", + repeatable: false, + icon: "https://render.guildwars2.com/file/509921D3BFDC049BC20758B71AD85592A043A439/1614870.png", + pips: 100, + }, + { + name: "Persimmon (Repeatable)", + repeatable: true, + icon: "https://render.guildwars2.com/file/5807B5E8BC4658DE9CB44664C125A6A3900D80A9/1614871.png", + pips: 100, + }, +]; diff --git a/src/App/season.ts b/src/App/season.ts index ebd45dc..a0772d3 100644 --- a/src/App/season.ts +++ b/src/App/season.ts @@ -1,15 +1,16 @@ import { state } from "@react-rxjs/core"; -import { of, switchMap, tap } from "rxjs"; +import { filter, map, of, switchMap, tap } from "rxjs"; import { getCurrentSeason$ } from "../service/gw2Api"; -import { cacheData$, writeCache$ } from "../service/localData"; +import { cacheData$, SeasonData, writeCache$ } from "../service/localData"; + +export function seasonIsActive(season: SeasonData | null) { + return season && new Date(season.end).getTime() >= new Date().getTime(); +} export const currentSeason$ = state( cacheData$.pipe( switchMap((cache) => { - if ( - cache?.seasonData && - new Date(cache.seasonData.end).getTime() >= new Date().getTime() - ) { + if (cache?.seasonData && seasonIsActive(cache.seasonData)) { return of(cache.seasonData); } @@ -24,3 +25,11 @@ export const currentSeason$ = state( ), null ); + +export const currentSeasonIsActive$ = state( + currentSeason$.pipe( + filter((v) => !!v), + map((v) => seasonIsActive(v!)) + ), + null +); diff --git a/src/service/gw2Api.ts b/src/service/gw2Api.ts index ebcc930..055a0df 100644 --- a/src/service/gw2Api.ts +++ b/src/service/gw2Api.ts @@ -14,27 +14,28 @@ export function getCurrentSeason$() { fetch(`https://api.guildwars2.com/v2/pvp/seasons/${seasonId}`) ), map((result) => result.data as any), - // tap((result) => console.log(result)), - map((result): SeasonData | null => - result.active - ? { - id: result.id, - name: result.name, - start: result.start, - end: result.end, - divisions: result.divisions.map((division: any) => ({ - name: division.name, - repeatable: division.flags.includes("Repeatable"), - pips: (division.tiers as Array).reduce( - (acc, tier) => acc + tier.points, - 0 - ), - icon: division.large_icon, - })), - } - : null + map( + (result): SeasonData => ({ + id: result.id, + name: result.name, + type: result.name.includes("3v3 Season") + ? "3v3" + : result.name.includes("2v2 Season") + ? "2v2" + : "5v5", + start: result.start, + end: result.end, + divisions: result.divisions.map((division: any) => ({ + name: division.name, + repeatable: division.flags.includes("Repeatable"), + pips: (division.tiers as Array).reduce( + (acc, tier) => acc + tier.points, + 0 + ), + icon: division.large_icon, + })), + }) ) - // tap((result) => console.log(result)) ); } diff --git a/src/service/localData.ts b/src/service/localData.ts index 3d8017e..329ae4a 100644 --- a/src/service/localData.ts +++ b/src/service/localData.ts @@ -70,18 +70,23 @@ export const writeConfig$ = (config: Partial) => ); /** CACHED DATA **/ +export interface Division { + name: string; + repeatable: boolean; + icon: string; + pips: number; +} +// This is the data needed to calculate everything, but it doesn't have data generated from API +export interface SeasonDetails { + type: "2v2" | "3v3" | "5v5"; + end: string; + divisions: Array; +} -export interface SeasonData { +export interface SeasonData extends SeasonDetails { id: string; name: string; start: string; - end: string; - divisions: Array<{ - name: string; - repeatable: boolean; - icon: string; - pips: number; - }>; } export interface LastResult { diff --git a/src/service/rewards.ts b/src/service/rewards.ts index 7c988ef..f89b321 100644 --- a/src/service/rewards.ts +++ b/src/service/rewards.ts @@ -1,4 +1,4 @@ -import { SeasonData } from "./localData"; +import { SeasonDetails } from "./localData"; interface Rewards { shardOfGlory: number; @@ -363,17 +363,18 @@ const mini3v3Rewards: Array = [ ]; export function getRewardForGoal( - season: SeasonData | null, + season: SeasonDetails | null, divisions: number, repeats: number ) { if (!season) return emptyReward; - const rewards = season.name.includes("3v3 Season") - ? mini3v3Rewards - : season.name.includes("2v2 Season") - ? mini2v2Rewards - : regularRewards; + const rewards = + season.type === "3v3" + ? mini3v3Rewards + : season.type === "2v2" + ? mini2v2Rewards + : regularRewards; const result = { ...emptyReward }; for (let i = 0; i < divisions; i++) { From 1ea8212a9e4a204a1ee5678b216c6a15a1b36b9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Oliva?= Date: Thu, 8 Dec 2022 21:57:46 +0100 Subject: [PATCH 2/2] feat: store configured season in user config --- src/App/playerDetailsState.ts | 43 +++++++++++++++++++++++++++++++++-- src/service/localData.ts | 20 +++++++++++++--- 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/src/App/playerDetailsState.ts b/src/App/playerDetailsState.ts index 443557d..e619422 100644 --- a/src/App/playerDetailsState.ts +++ b/src/App/playerDetailsState.ts @@ -8,6 +8,7 @@ import { merge, Observable, switchMap, + take, tap, withLatestFrom, } from "rxjs"; @@ -117,9 +118,47 @@ export const playerDetails$ = state( null ); -export const selectedType$ = state(selectedTypeChange$, "2v2"); +export const selectedType$ = state( + merge( + initialConfig$.pipe(map((v) => v.season.type)), + selectedTypeChange$.pipe( + tap((type) => { + initialConfig$ + .pipe( + map((v) => v.season.end), + take(1), + switchMap((end) => + writeConfig$({ + season: { type, end }, + }) + ) + ) + .subscribe(); + }) + ) + ), + "2v2" +); export const endDate$ = state( - endDateChange$.pipe(map((v) => (v ? v : null))), + merge( + initialConfig$.pipe(map((v) => v.season.end ?? null)), + endDateChange$.pipe( + map((v) => (v ? v : null)), + tap((end) => { + initialConfig$ + .pipe( + map((v) => v.season.type), + take(1), + switchMap((type) => + writeConfig$({ + season: { type, end: end ?? undefined }, + }) + ) + ) + .subscribe(); + }) + ) + ), null ); diff --git a/src/service/localData.ts b/src/service/localData.ts index 329ae4a..9b33e6e 100644 --- a/src/service/localData.ts +++ b/src/service/localData.ts @@ -1,4 +1,4 @@ -import { shareLatest } from "@react-rxjs/core"; +import { shareLatest, state } from "@react-rxjs/core"; import { BaseDirectory, createDir, @@ -12,10 +12,16 @@ export interface Goal { value: number; } +export interface ManualSeason { + type: "2v2" | "3v3" | "5v5"; + end: string | undefined; +} + export interface Config { version: 1; apiKey: string; holidays: number[]; // day0 = start of season + season: ManualSeason; goals: Goal[]; } @@ -23,6 +29,10 @@ const defaultConfig: Config = { version: 1, apiKey: "", holidays: [], + season: { + type: "2v2", + end: undefined, + }, goals: [ { title: "All chests", @@ -38,14 +48,18 @@ const readConfig$ = () => dir: BaseDirectory.App, }); - return JSON.parse(content) as Config; + const parsed = JSON.parse(content) as Partial; + return { + ...defaultConfig, + ...parsed, + }; } catch (ex) { console.error(ex); return defaultConfig; } }); -export const initialConfig$ = readConfig$().pipe(shareLatest()); +export const initialConfig$ = state(readConfig$()); export const writeConfig$ = (config: Partial) => readConfig$().pipe(