First commit of os-league-tools-master

This commit is contained in:
2025-10-27 08:36:10 +08:00
parent a5aab68ea4
commit 31ee652bff
528 changed files with 114970 additions and 0 deletions

View File

@@ -0,0 +1,109 @@
import React from 'react';
import Card from '../components/common/Card';
import Separator from '../components/common/Separator';
import PageWrapper from '../components/PageWrapper';
export default function About() {
const emphasisedText = 'text-accent font-semibold';
return (
<PageWrapper>
<div className='container lg:max-w-[768px] mx-auto'>
<Card>
<Card.Header className=''>
<p className='text-accent font-bold text-center small-caps text-2xl tracking-widest'>OS-LEAGUE-TOOLS</p>
<p className='text-accent font-semibold text-center'>
open source trackers, tools, and more for Old School Runescape's seasonal leagues gamemode
</p>
</Card.Header>
<Card.Body>
<Separator />
<Header>about</Header>
<Paragraph>
OS League Tools was originally launched for Trailblazer League by{' '}
<Link text='chaiinchomp' href='https://github.com/chaiinchomp' />, a runescape veteran since 2005. After
spending all of the first league making checklists and custom calculators in a spreadsheet, a website was
the obvious next step.
</Paragraph>
<Paragraph>
A special massive shoutout goes to <Link text='perterter' href='https://github.com/tylerthardy' />, who
developed the accompanying RuneLite plugin, without which I'm sure the site would never have taken off as
much as it did.
</Paragraph>
<Separator />
<Header>contributing</Header>
<Paragraph>
If you're interested in supporting the site's development, you can do so by (most importantly) continuing
to use it and spread the word to others! This is a passion project built entirely by volunteers, so the
biggest reward is just to see people using what we've created.
</Paragraph>
<Paragraph>
New code contributors are always welcome, too. Check out the source code on{' '}
<Link text='github' href='https://github.com/osrs-reldo/os-league-tools' />, and come by the{' '}
<span className='text-code'>#development</span> channel in{' '}
<Link text='discord' href='https://discord.gg/GQ5kVyU' />
to chat, ask questions, and see which features and bugs need help.
</Paragraph>
<Paragraph>
Finally, if you would like to support the site financially, you can drop a few bucks in the{' '}
<Link text='tip jar' href='https://ko-fi.com/osleaguetools' />. All funds go directly into paying the
site's hosting costs.
</Paragraph>
<Separator />
<Header>credits</Header>
<div className='m-2 grid md:grid-cols-4 grid-cols-2'>
<span className={`${emphasisedText} whitespace-nowrapp md:text-right`}>developed using:</span>
<ul className='col-span-3 list-disc text-sm mb-3 ml-6'>
<li>React/JS with Tailwind CSS</li>
<li>Additional libraries: osrs-json-hiscores</li>
</ul>
<span className={`${emphasisedText} whitespace-nowrap md:text-right`}>with help from:</span>
<ul className='col-span-3 list-disc text-sm ml-6'>
<li>
All of the awesome people who have contributed code, bug reports, and feedback in the{' '}
<Link text='discord' href='https://discord.gg/GQ5kVyU' newTab />
</li>
<li>
Massive amount of data, images, and more from the{' '}
<Link text='Official OSRS Wiki' href='https://oldschool.runescape.wiki/' />
(with help figuring out how to parse the wiki API from{' '}
<Link text='osrsbox' href='https://www.osrsbox.com/blog/2018/12/12/scraping-the-osrs-wiki-part1/' />)
</li>
<li>
Lots of helpful research and data compilations from the folks in the{' '}
<Link text='OSRS Leagues Discord' href='http://discord.osrs-leagues.com/' />
</li>
<li>
Icons from <Link text='google fonts' href='https://fonts.google.com/icons' />
(+ RuneLite icon from <Link text='runelite.net' href='https://runelite.net/' />)
</li>
<li>
Some obscure numbers used in skill calculators from tweeting questions at{' '}
<Link text='Mod Ash' href='https://twitter.com/JagexAsh' />
</li>
<li>
...and all of you <span className={`icon-base inline align-sub ${emphasisedText}`}>favorite</span>
</li>
</ul>
</div>
</Card.Body>
</Card>
</div>
</PageWrapper>
);
}
function Header({ children }) {
return <p className='text-accent font-semibold text-center tracking-widest m-2'>{children}</p>;
}
function Link({ text, href }) {
return (
<a href={href} target='_blank' rel='noreferrer' className='hover:underline text-accent font-semibold'>
{text}
</a>
);
}
function Paragraph({ children }) {
return <p className='indent-8 m-2 text-sm'>{children}</p>;
}

View File

@@ -0,0 +1,16 @@
import React from 'react';
import Card from '../components/common/Card';
import PageWrapper from '../components/PageWrapper';
import BankedExpPanel from './BankedExpPanel';
export default function BankedExp() {
return (
<PageWrapper>
<Card>
<Card.Body>
<BankedExpPanel />
</Card.Body>
</Card>
</PageWrapper>
);
}

View File

@@ -0,0 +1,54 @@
import React, { useState } from 'react';
import useBreakpoint, { MEDIA_QUERIES, MODE } from '../hooks/useBreakpoint';
import BankedExpSettings from '../components/BankedExpSettings';
import BankedExpTable from '../components/BankedExpTable';
import useMultipliers from '../hooks/useMultipliers';
import useEquilibrium from '../hooks/useEquilibrium';
export default function BankedExpPanel() {
const isSmViewport = useBreakpoint(MEDIA_QUERIES.SM, MODE.LESS_OR_EQ);
const isXlViewport = useBreakpoint(MEDIA_QUERIES.XL);
const [showSidebar, setShowSidebar] = useState(isXlViewport);
const [expGained, setExpGained] = useState(0);
const multipliersState = useMultipliers();
const equilibriumState = useEquilibrium();
return (
<section className='flex flex-col xl:flex-row w-full bg-secondary-alt xl:bg-primary'>
{isSmViewport && showSidebar && (
<div className='mt-3 bg-hover cursor-pointer' onClick={() => setShowSidebar(!showSidebar)}>
<span className='icon-xl align-middle'>keyboard_double_arrow_up</span>
<span className='text-sm italic ml-1'>Hide settings</span>
</div>
)}
{showSidebar && (
<div className='basis-[23%] p-2'>
<BankedExpSettings
expGained={expGained}
multipliersState={multipliersState}
equilibriumState={equilibriumState}
/>
</div>
)}
<div className='mt-3 mb-3 bg-hover cursor-pointer' onClick={() => setShowSidebar(!showSidebar)}>
{isXlViewport ? (
<span className='icon-xl align-middle'>
{showSidebar ? 'keyboard_double_arrow_left' : 'keyboard_double_arrow_right'}
</span>
) : (
<p className='text-sm italic ml-1'>
<span className='icon-xl align-middle'>
{showSidebar ? 'keyboard_double_arrow_up' : 'keyboard_double_arrow_down'}
</span>
{showSidebar ? 'Hide settings' : 'Show settings'}
</p>
)}
</div>
<BankedExpTable
setExpGained={setExpGained}
multipliersState={multipliersState}
equilibriumState={equilibriumState}
/>
</section>
);
}

View File

@@ -0,0 +1,26 @@
import React from 'react';
import images from '../assets/images';
import TabbedCard from '../components/common/TabbedCard';
import PageWrapper from '../components/PageWrapper';
import useFetchHiscoresOnLoad from '../hooks/useFetchHiscoresOnLoad';
import useQueryString from '../hooks/useQueryString';
import BankedExpPanel from './BankedExpPanel';
import CalculatorsPanel from './CalculatorsPanel';
export default function Calculators() {
const [selectedTab, onSetSelectedTab] = useQueryString('tab');
useFetchHiscoresOnLoad();
return (
<PageWrapper>
<TabbedCard defaultActiveTab={selectedTab} setTabCallback={onSetSelectedTab}>
<TabbedCard.Tab id='character' label='Skill calculators' icon={images['tab-stats.png']}>
<CalculatorsPanel />
</TabbedCard.Tab>
<TabbedCard.Tab id='bankedExp' label='Banked exp' icon={images['tab-inventory.png']}>
<BankedExpPanel />
</TabbedCard.Tab>
</TabbedCard>
</PageWrapper>
);
}

View File

@@ -0,0 +1,55 @@
import React, { useState } from 'react';
import { useSelector } from 'react-redux';
import useBreakpoint, { MEDIA_QUERIES, MODE } from '../hooks/useBreakpoint';
import CalculatorSettings from '../components/CalculatorSettings';
import CalculatorTable from '../components/CalculatorTable';
import useMultipliers from '../hooks/useMultipliers';
import useEquilibrium from '../hooks/useEquilibrium';
export default function CalculatorsPanel() {
const {
calculators: { skill, expValues, baseMultiplier },
} = useSelector(state => ({ calculators: state.calculators, tasks: state.tasks }));
const isSmViewport = useBreakpoint(MEDIA_QUERIES.SM, MODE.LESS_OR_EQ);
const isXlViewport = useBreakpoint(MEDIA_QUERIES.XL);
const [showSidebar, setShowSidebar] = useState(isXlViewport);
const multipliersState = useMultipliers();
const equilibriumState = useEquilibrium();
return (
<section className='flex flex-col xl:flex-row w-full bg-secondary-alt xl:bg-primary'>
{isSmViewport && showSidebar && (
<div className='mt-3 bg-hover cursor-pointer' onClick={() => setShowSidebar(!showSidebar)}>
<span className='icon-xl align-middle'>keyboard_double_arrow_up</span>
<span className='text-sm italic ml-1'>Hide settings</span>
</div>
)}
{showSidebar && (
<div className='basis-[23%] p-2'>
<CalculatorSettings skill={skill} multipliersState={multipliersState} equilibriumState={equilibriumState} />
</div>
)}
<div className='mt-3 mb-3 bg-hover cursor-pointer' onClick={() => setShowSidebar(!showSidebar)}>
{isXlViewport ? (
<span className='icon-xl align-middle'>
{showSidebar ? 'keyboard_double_arrow_left' : 'keyboard_double_arrow_right'}
</span>
) : (
<p className='text-sm italic ml-1'>
<span className='icon-xl align-middle'>
{showSidebar ? 'keyboard_double_arrow_up' : 'keyboard_double_arrow_down'}
</span>
{showSidebar ? 'Hide settings' : 'Show settings'}
</p>
)}
</div>
<CalculatorTable
skill={skill}
expValues={expValues}
baseMultiplier={baseMultiplier}
multipliersState={multipliersState}
equilibriumState={equilibriumState}
/>
</section>
);
}

View File

@@ -0,0 +1,59 @@
import React, { useState } from 'react';
import { useSelector } from 'react-redux';
import SkillsPanel from '../components/SkillsPanel';
import BossesPanel from '../components/BossesPanel';
import ManageCharactersModal from '../components/ManageCharactersModal';
import CharacterRegionsSection from '../components/CharacterRegionsSection';
import CharacterRelicsSection from '../components/CharacterRelicsSection';
import CharacterTotalsSection from '../components/CharacterTotalsSection';
export default function CharacterPanel() {
const [isCharacterModalOpen, setCharacterModalOpen] = useState(false);
const characterState = useSelector(state => state.character);
const username = characterState.characters[characterState.activeCharacter];
const { taskStats, tier } = useSelector(state => state.tasks);
const unlockState = useSelector(state => state.unlocks);
if (!username) {
return (
<div>
<ManageCharactersModal
isOpen={isCharacterModalOpen}
setIsOpen={val => setCharacterModalOpen(val)}
initialAddModalOpen
/>
<p className='text-accent text-center small-caps text-2xl'>No character found</p>
<p className='font-semibold text-center'>
To use the character tracker,{' '}
<button className='text-accent hover:underline' type='button' onClick={() => setCharacterModalOpen(true)}>
set your username
</button>
!
</p>
</div>
);
}
return (
<div className='flex flex-col w-full items-stretch'>
<p className='text-center text-accent text-4xl font-mono uppercase tracking-widest'>{username}</p>
<div className='flex md:flex-row flex-col lg:flex-nowrap flex-wrap justify-around w-full items-stretch md:gap-1 gap-3'>
{/* LEFT COLUMN */}
<div className='lg:basis-1/4 basis-2/5 flex flex-col h-full items-center gap-3 order-3 lg:order-1'>
<CharacterTotalsSection taskStats={taskStats} />
</div>
{/* CENTER COLUMN */}
<div className='lg:basis-1/2 basis-full flex flex-col items-center gap-3 order-1 lg:order-3 shrink'>
<CharacterRegionsSection taskStats={taskStats} unlockedRegions={unlockState.regions} />
<CharacterRelicsSection tier={tier} taskStats={taskStats} unlockedRelics={unlockState.relics} />
</div>
{/* RIGHT COLUMN */}
<div className='lg:basis-1/4 basis-2/5 flex flex-col items-center order-5 gap-3'>
<span className='text-lg text-accent font-semibold border-b border-accent w-full'>Stats</span>
<SkillsPanel />
<BossesPanel />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,43 @@
import React, { useState } from 'react';
import DiariesFilters from '../components/DiariesFilters';
import DiariesTable from '../components/DiariesTable';
import useBreakpoint, { MEDIA_QUERIES, MODE } from '../hooks/useBreakpoint';
export default function DiariesPanel() {
const isSmViewport = useBreakpoint(MEDIA_QUERIES.SM, MODE.LESS_OR_EQ);
const isXlViewport = useBreakpoint(MEDIA_QUERIES.XL);
const [showSidebar, setShowSidebar] = useState(isXlViewport);
return (
<div className='flex flex-col xl:flex-row w-full bg-secondary-alt xl:bg-primary'>
{isSmViewport && showSidebar && (
<div className='mt-3 bg-hover cursor-pointer' onClick={() => setShowSidebar(!showSidebar)}>
<span className='icon-xl align-middle'>keyboard_double_arrow_up</span>
<span className='text-sm italic ml-1'>Hide filters</span>
</div>
)}
{showSidebar && (
<div className='basis-[23%] p-2'>
<DiariesFilters />
</div>
)}
<div className='mt-3 mb-3 bg-hover cursor-pointer' onClick={() => setShowSidebar(!showSidebar)}>
{isXlViewport ? (
<span className='icon-xl align-middle'>
{showSidebar ? 'keyboard_double_arrow_left' : 'keyboard_double_arrow_right'}
</span>
) : (
<>
<span className='icon-xl align-middle'>
{showSidebar ? 'keyboard_double_arrow_up' : 'keyboard_double_arrow_down'}
</span>
<span className='text-sm italic ml-1'>{showSidebar ? 'Hide filters' : 'Show filters'}</span>
</>
)}
</div>
<div className='basis-3/4 grow flex flex-col xl:ml-1 bg-primary'>
<DiariesTable />
</div>
</div>
);
}

View File

@@ -0,0 +1,106 @@
import React from 'react';
import Card from '../components/common/Card';
import Separator from '../components/common/Separator';
import PageWrapper from '../components/PageWrapper';
export default function Faq() {
const emphasisedText = 'text-accent font-semibold';
return (
<PageWrapper>
<div className='container lg:max-w-[768px] mx-auto'>
<Card>
<Card.Header className=''>
<p className='text-accent font-bold text-center small-caps text-2xl tracking-widest'>
FREQUENTLY ASKED QUESTIONS
</p>
<p className='text-accent font-semibold text-center'>
If your question is missing, come ask us in <Link text='discord' href='https://discord.gg/GQ5kVyU' />!
</p>
</Card.Header>
<Card.Body>
<Separator />
<Header>How do I use the plugin on OpenOSRS / some other client?</Header>
<Paragraph>
OS League Tools <span className={emphasisedText}>does not</span> officially support any clients other than
RuneLite.
</Paragraph>
<Paragraph>
If the plugin is broken on the client you are using, it is most likely caused by an out-of-date version.
Generally this will be synced within a day or two, but it is outside my control. For the most stable
experience, use the official RuneLite client only.
</Paragraph>
<Separator />
<Header>How do I import tasks from the plugin to the site?</Header>
<Paragraph>
<ol>
<li>
1. Click the sidebar icon to open the Tasks Tracker plugin panel. Make sure "Leagues IV: Trailblazer
Reloaded" is selected on the dropdown menu.
</li>
<li>
2. Click the "Export" button at the bottom of the panel. Your task data will be automatically copied
to your clipboard.
</li>
<li>
3. Open https://www.osleague.tools and go to Manage Data -{'>'} Import. Paste your data into the text
box and click "Sync".
</li>
</ol>
</Paragraph>
<Separator />
<Header>How do I export my to-do list to the plugin?</Header>
<Paragraph>
<ol>
<li>
1. Open https://www.osleague.tools and go to Manage Data -{'>'} Export. Click on the text box to copy
your data to the clipboard.
</li>
<li>
2. On Runelite, click the sidebar icon to open the Tasks Tracker plugin panel. Make sure "Leagues IV:
Trailblazer Reloaded" is selected on the dropdown menu.
</li>
<li>3. Click the "Import" button at the bottom of the panel and paste into the text box.</li>
</ol>
</Paragraph>
<Separator />
<Header>Importing tasks isn't working / Tasks aren't showing complete when they should</Header>
<Paragraph>
First, double check that you have the latest plugin version. You can see this by searching for{' '}
<span className={emphasisedText}>Task Tracker</span> in the plugin hub. If it has an option to update,
click it and retry your export.
</Paragraph>
<Paragraph>
As a last resort, you can also try:
<ol>
<li>1. Rebooting the runelite client and/or reinstalling the plugin</li>
<li>2. Clear your browser data on the website to start fresh</li>
</ol>
</Paragraph>
<Paragraph>
If none of that helped, send a bug report (use the Feedback option in the top right menu) with as much
information as you can, or come by the <Link text='discord' href='https://discord.gg/GQ5kVyU' /> to ask
for help.
</Paragraph>
<Separator />
</Card.Body>
</Card>
</div>
</PageWrapper>
);
}
function Header({ children }) {
return <p className='text-accent font-semibold text-center tracking-widest m-2'>{children}</p>;
}
function Link({ text, href }) {
return (
<a href={href} target='_blank' rel='noreferrer' className='hover:underline text-accent font-semibold'>
{text}
</a>
);
}
function Paragraph({ children }) {
return <p className='indent-8 m-2 text-sm'>{children}</p>;
}

View File

@@ -0,0 +1,39 @@
import React, { useState } from 'react';
import newsPosts from '../data/newsPosts.json';
import NewsCard from '../components/NewsCard';
import PageWrapper from '../components/PageWrapper';
import IconLinkCard from '../components/IconLinkCard';
import LeagueCountdown from '../components/LeagueCountdown';
import FeedbackModal from '../components/FeedbackModal';
import ManageDataModal from '../components/ManageDataModal';
import images from '../assets/images';
export default function Homepage() {
const [isFeedbackModalOpen, setFeedbackModalOpen] = useState(false);
const [isPluginModalOpen, setPluginModalOpen] = useState(false);
return (
<PageWrapper>
<div className='md:flex md:flex-row justify-center'>
<IconLinkCard title='Discord' href='https://discord.gg/GQ5kVyU' target='_blank' />
<IconLinkCard title='Plugin' iconSrc={images['runelite-icon.svg']} onClick={() => setPluginModalOpen(true)} />
<LeagueCountdown />
<IconLinkCard title='Feedback' iconText='pest_control' onClick={() => setFeedbackModalOpen(true)} />
<IconLinkCard title='About' iconText='help_outline' href='/about' />
</div>
<FeedbackModal isOpen={isFeedbackModalOpen} setIsOpen={val => setFeedbackModalOpen(val)} />
<ManageDataModal variant='plugin' isOpen={isPluginModalOpen} setIsOpen={val => setPluginModalOpen(val)} />
<p className='text-3xl small-caps ml-1 mt-2'>Updates</p>
{newsPosts.map(newsPost => (
<NewsCard
key={newsPost.title}
title={newsPost.title}
date={newsPost.date}
coverImg={newsPost.thumbnail}
leadText={newsPost.leadText}
htmlContent={newsPost.htmlContent}
/>
))}
</PageWrapper>
);
}

View File

@@ -0,0 +1,43 @@
import React, { useState } from 'react';
import QuestFilters from '../components/QuestFilters';
import QuestTable from '../components/QuestTable';
import useBreakpoint, { MEDIA_QUERIES, MODE } from '../hooks/useBreakpoint';
export default function QuestsPanel() {
const isSmViewport = useBreakpoint(MEDIA_QUERIES.SM, MODE.LESS_OR_EQ);
const isXlViewport = useBreakpoint(MEDIA_QUERIES.XL);
const [showSidebar, setShowSidebar] = useState(isXlViewport);
return (
<div className='flex flex-col xl:flex-row w-full bg-secondary-alt xl:bg-primary'>
{isSmViewport && showSidebar && (
<div className='mt-3 bg-hover cursor-pointer' onClick={() => setShowSidebar(!showSidebar)}>
<span className='icon-xl align-middle'>keyboard_double_arrow_up</span>
<span className='text-sm italic ml-1'>Hide filters</span>
</div>
)}
{showSidebar && (
<div className='basis-[23%] p-2'>
<QuestFilters />
</div>
)}
<div className='mt-3 mb-3 bg-hover cursor-pointer' onClick={() => setShowSidebar(!showSidebar)}>
{isXlViewport ? (
<span className='icon-xl align-middle'>
{showSidebar ? 'keyboard_double_arrow_left' : 'keyboard_double_arrow_right'}
</span>
) : (
<>
<span className='icon-xl align-middle'>
{showSidebar ? 'keyboard_double_arrow_up' : 'keyboard_double_arrow_down'}
</span>
<span className='text-sm italic ml-1'>{showSidebar ? 'Hide filters' : 'Show filters'}</span>
</>
)}
</div>
<div className='basis-3/4 grow flex flex-col xl:ml-1 bg-primary'>
<QuestTable />
</div>
</div>
);
}

View File

@@ -0,0 +1,156 @@
import React from 'react';
import { useSelector, useDispatch, batch } from 'react-redux';
import { Tooltip as ReactTooltip } from 'react-tooltip';
import { update } from '../store/settings/settings';
import LabeledCheckbox from '../components/common/LabeledCheckbox';
import TabbedCard from '../components/common/TabbedCard';
import PageWrapper from '../components/PageWrapper';
import images from '../assets/images';
export default function Settings() {
const settingsState = useSelector(state => state.settings);
const dispatch = useDispatch();
return (
<PageWrapper>
<div className='mx-auto'>
<TabbedCard defaultActiveTab='interface'>
<TabbedCard.Tab id='interface' label='Interface'>
<div className='grid xl:grid-cols-2'>
<div>
<span className='heading-block-md small-caps mb-2 text-accent'>
General{' '}
<span className='icon-outline text-xs inline mr-1' data-tip data-for='general'>
info
</span>
</span>
<ReactTooltip id='general'>
<p className='text-sm italic'>
On large screens, site content will be limited to a maximum of 1500px.
</p>
<p className='text-sm italic'>Uncheck if you wish to use the full width of your browser window.</p>
</ReactTooltip>
<div className='ml-2'>
<LabeledCheckbox
label='Limit maximum content width'
checked={settingsState.limitContentWidth}
onClick={e => dispatch(update({ field: 'limitContentWidth', value: e.target.checked }))}
/>
</div>
<span className='heading-block-md small-caps mb-2 mt-2 text-accent'>
Task tracker{' '}
<span className='icon-outline text-xs inline mr-1' data-tip data-for='taskTracker'>
info
</span>
</span>
<ReactTooltip id='taskTracker'>
<p className='text-sm italic'>Choose which columns to show on the task tracker.</p>
<p className='text-sm italic'>
Note that on small screens, some columns may be hidden regardless of this setting.
</p>
</ReactTooltip>
<div className='ml-2'>
<LabeledCheckbox
label='Show "Priority" column'
checked={settingsState.taskColumns.priority}
onClick={e =>
dispatch(update({ field: 'taskColumns', subfield: 'priority', value: e.target.checked }))
}
/>
<LabeledCheckbox
label='Show "Category" column'
checked={settingsState.taskColumns.category}
onClick={e =>
dispatch(update({ field: 'taskColumns', subfield: 'category', value: e.target.checked }))
}
/>
<LabeledCheckbox
label='Show "Completed At" column'
checked={settingsState.taskColumns.completedAt}
onClick={e =>
dispatch(update({ field: 'taskColumns', subfield: 'completedAt', value: e.target.checked }))
}
/>
<LabeledCheckbox
label='Show "Regions" column'
checked={settingsState.taskColumns.regions}
onClick={e =>
dispatch(update({ field: 'taskColumns', subfield: 'regions', value: e.target.checked }))
}
/>
</div>
</div>
<div>
<span className='heading-block-md small-caps my-2 text-accent'>Mode</span>
<div className='ml-2 mb-4 flex flex-row flex-wrap gap-4'>
<ModeSelectCard label='Dark' mode='dark' />
<ModeSelectCard label='Light' mode='light' />
</div>
<span className='heading-block-md small-caps my-2 text-accent'>Theme</span>
<div className='ml-2 flex flex-row flex-wrap gap-4'>
<ThemeSelectCard label='Twisted' theme='tl' />
<ThemeSelectCard label='Trailblazer' theme='tb' />
<ThemeSelectCard label='Shattered' theme='sl' />
<ThemeSelectCard label='Reloaded' theme='tr' />
<ThemeSelectCard label='Echoes' theme='re' />
<ThemeSelectCard label='Mono' theme='mono' />
</div>
</div>
</div>
</TabbedCard.Tab>
</TabbedCard>
</div>
</PageWrapper>
);
}
function ModeSelectCard({ label, mode }) {
const activeTheme = useSelector(state => state.settings.theme);
const activeMode = useSelector(state => state.settings.mode);
const dispatch = useDispatch();
const themeMode = `${activeTheme.split('-')[0]}-${mode}`;
const selected = activeMode === mode;
const selectedStyle = selected ? 'border-x-2 border-accent bg-secondary-alt' : 'cursor-pointer bg-hover';
return (
<div
className={`rounded p-2 w-[100px] min-w-[100px] ${selectedStyle}`}
onClick={() =>
batch(() => {
dispatch(update({ field: 'theme', value: themeMode }));
dispatch(update({ field: 'mode', value: mode }));
})
}
>
<img className='h-9 w-9 mx-auto' src={images[`icon-blank-${mode}.png`]} alt='' />
<span className={`text-center heading-block-sm pt-2 small-caps force-wrap ${selected && 'text-accent'}`}>
{label}
</span>
</div>
);
}
function ThemeSelectCard({ label, theme }) {
const activeTheme = useSelector(state => state.settings.theme);
const activeMode = useSelector(state => state.settings.mode);
const dispatch = useDispatch();
const themeMode = `${theme}-${activeMode}`;
const selected = activeTheme === themeMode;
const selectedStyle = selected ? 'border-x-2 border-accent bg-secondary-alt' : 'cursor-pointer bg-hover';
return (
<div
className={`rounded p-2 my-auto w-[100px] min-w-[100px] ${selectedStyle}`}
onClick={() =>
batch(() => {
dispatch(update({ field: 'theme', value: themeMode }));
})
}
>
<img className='h-9 w-9 mx-auto' src={images[`icon-${theme}-split.png`]} alt='' />
<span className={`text-center heading-block-sm pt-2 small-caps force-wrap ${selected && 'text-accent'}`}>
{label}
</span>
</div>
);
}

View File

@@ -0,0 +1,198 @@
import { forEach } from 'lodash';
import React, { useMemo } from 'react';
import { Chart } from 'react-charts';
import { useSelector } from 'react-redux';
import Card from '../components/common/Card';
import PageWrapper from '../components/PageWrapper';
import { LEAGUE_END_DATE, LEAGUE_START_DATE } from '../data/constants';
import tasks from '../data/tasks';
import getAccentColorForTheme from '../util/colors';
const DATE_FORMAT = 'en-US';
const DATE_OPTIONS = { month: 'short', day: 'numeric' };
function createLeagueStats(dataByCompletionDate) {
const nextDate = new Date(LEAGUE_START_DATE.getTime());
nextDate.setHours(0);
const taskCounts = [];
const pointCounts = [];
while (nextDate <= LEAGUE_END_DATE) {
taskCounts.push({
date: new Date(nextDate.getTime()).toLocaleDateString(DATE_FORMAT, DATE_OPTIONS),
count: dataByCompletionDate[nextDate]?.tasksComplete ?? 0,
secondaryAxisId: 'daily',
});
pointCounts.push({
date: new Date(nextDate.getTime()).toLocaleDateString(DATE_FORMAT, DATE_OPTIONS),
count: dataByCompletionDate[nextDate]?.pointsEarned ?? 0,
secondaryAxisId: 'daily',
});
nextDate.setDate(nextDate.getDate() + 1);
}
const cumulativeTaskCounts = [];
const cumulativePointCounts = [];
let cumulativeTasks = 0;
let cumulativePoints = 0;
for (let i = 0; i < taskCounts.length; i++) {
cumulativeTasks += taskCounts[i].count;
cumulativePoints += pointCounts[i].count;
cumulativeTaskCounts.push({
date: taskCounts[i].date,
count: cumulativeTasks,
});
cumulativePointCounts.push({
date: taskCounts[i].date,
count: cumulativePoints,
});
}
return [
{
label: 'Tasks completed (daily)',
data: taskCounts,
elementType: 'bar',
},
{
label: 'Tasks completed (total)',
data: cumulativeTaskCounts,
},
{
label: 'Points earned (daily)',
data: pointCounts,
elementType: 'bar',
},
{
label: 'Points earned (total)',
data: cumulativePointCounts,
},
];
}
function createDataByCompletionDate(taskState) {
const dataByCompletionDate = {};
forEach(Object.keys(taskState.tasks), taskKey => {
if (taskState.tasks[taskKey].completed) {
const completedDate = new Date(0);
completedDate.setUTCSeconds(taskState.tasks[taskKey].completed / 1000);
completedDate.setHours(0);
completedDate.setMinutes(0);
completedDate.setSeconds(0);
if (dataByCompletionDate[completedDate]?.tasksComplete) {
dataByCompletionDate[completedDate].tasksComplete += 1;
} else if (dataByCompletionDate[completedDate]) {
dataByCompletionDate[completedDate].tasksComplete = 1;
} else {
dataByCompletionDate[completedDate] = {};
dataByCompletionDate[completedDate].tasksComplete = 1;
}
const pointValue = tasks[taskKey]?.difficulty.value;
if (dataByCompletionDate[completedDate]?.pointsEarned) {
dataByCompletionDate[completedDate].pointsEarned += pointValue;
} else if (dataByCompletionDate[completedDate]) {
dataByCompletionDate[completedDate].pointsEarned = pointValue;
} else {
dataByCompletionDate[completedDate] = {};
dataByCompletionDate[completedDate].pointsEarned = pointValue;
}
}
});
return dataByCompletionDate;
}
export default function Statistics() {
const taskState = useSelector(state => state.tasks);
const theme = useSelector(state => state.settings.theme);
const leagueStats = useMemo(() => createLeagueStats(createDataByCompletionDate(taskState)), [taskState]);
const primaryAxis = useMemo(
() => ({
getValue: datum => datum.date,
}),
[]
);
const secondaryAxes = useMemo(
() => [
{
getValue: datum => datum.count,
elementType: 'line',
},
{
getValue: datum => datum.count,
elementType: 'bar',
},
],
[]
);
return (
<PageWrapper>
<div className='container h-[800px] mx-auto'>
<Card className='h-full'>
<Card.Body>
<div className='grid grid-cols-2 gap-4 h-full'>
<div className='h-[90%] pb-3'>
<h1 className='heading-accent-md'>Tasks completed per day</h1>
<Chart
options={{
data: [leagueStats[0]],
primaryAxis,
secondaryAxes: [secondaryAxes[1]],
getSeriesStyle: () => ({
color: getAccentColorForTheme(theme),
}),
dark: theme.split('-')[1] === 'dark',
}}
/>
</div>
<div className='h-[90%] pb-3'>
<h1 className='heading-accent-md'>Points earned per day</h1>
<Chart
options={{
data: [leagueStats[2]],
primaryAxis,
secondaryAxes: [secondaryAxes[1]],
getSeriesStyle: () => ({
color: getAccentColorForTheme(theme),
}),
dark: theme.split('-')[1] === 'dark',
}}
/>
</div>
<div className='h-[90%] pb-3'>
<h1 className='heading-accent-md'>Cumulative tasks completed</h1>
<Chart
options={{
data: [leagueStats[1]],
primaryAxis,
secondaryAxes: [secondaryAxes[0]],
getSeriesStyle: () => ({
color: getAccentColorForTheme(theme),
}),
dark: theme.split('-')[1] === 'dark',
}}
/>
</div>
<div className='h-[90%] pb-3'>
<h1 className='heading-accent-md'>Cumulative points earned</h1>
<Chart
options={{
data: [leagueStats[3]],
primaryAxis,
secondaryAxes: [secondaryAxes[0]],
getSeriesStyle: () => ({
color: getAccentColorForTheme(theme),
}),
dark: theme.split('-')[1] === 'dark',
}}
/>
</div>
</div>
</Card.Body>
</Card>
</div>
</PageWrapper>
);
}

View File

@@ -0,0 +1,112 @@
import React, { useState } from 'react';
import { useSelector } from 'react-redux';
import { ThemedProgressBar } from '../components/ThemeProvider';
import Separator from '../components/common/Separator';
import TaskFilters from '../components/TaskFilters';
import TaskGenerator from '../components/TaskGenerator';
import TaskTable from '../components/TaskTable';
import useBreakpoint, { MEDIA_QUERIES, MODE } from '../hooks/useBreakpoint';
import { RELIC_UNLOCK_THRESHOLDS } from '../data/relics';
import useTrackerHistory from '../hooks/useTrackerHistory';
import { getRegionTier } from '../util/getTier';
import { REGION_UNLOCK_THRESHOLDS } from '../data/regions';
export default function TasksPanel({ readonly, taskState }) {
const isSmViewport = useBreakpoint(MEDIA_QUERIES.SM, MODE.LESS_OR_EQ);
const isXlViewport = useBreakpoint(MEDIA_QUERIES.XL);
const [showSidebar, setShowSidebar] = useState(isXlViewport);
const { taskStats, tier } = useSelector(state => state.tasks);
const regionTier = getRegionTier(taskStats.tasks.complete.total);
const history = useTrackerHistory();
return (
<div className='h-full'>
<div className='mb-3'>
<div className='flex flex-wrap text-accent font-semibold justify-evenly gap-2'>
<span>
Tasks: {taskStats.tasks.complete.total} / {taskStats.tasks.available.total}
</span>
<span>
Points: {taskStats.points.complete.total} / {taskStats.points.available.total}
</span>
<span>
To-do: {taskStats.tasks.todo.total} tasks ({taskStats.points.todo.total} points)
</span>
</div>
<div className='flex flex-row w-full gap-5 mt-3'>
<div className='basis-1/2'>
<div className='mb-1'>
<ThemedProgressBar
curValue={taskStats.points.complete.total}
maxValue={RELIC_UNLOCK_THRESHOLDS[RELIC_UNLOCK_THRESHOLDS.length - 1]}
steps={RELIC_UNLOCK_THRESHOLDS}
/>
</div>
<div className='text-accent text-sm text-center'>
{tier < RELIC_UNLOCK_THRESHOLDS.length ? (
<span>{`Next relic unlocked at ${RELIC_UNLOCK_THRESHOLDS[tier]} pts (${
RELIC_UNLOCK_THRESHOLDS[tier] - taskStats.points.complete.total
} remaining)`}</span>
) : (
'All relics unlocked'
)}
</div>
</div>
<div className='basis-1/2'>
<div className='shadow-subdued mb-1'>
<ThemedProgressBar
curValue={taskStats.tasks.complete.total}
maxValue={REGION_UNLOCK_THRESHOLDS[REGION_UNLOCK_THRESHOLDS.length - 1]}
steps={REGION_UNLOCK_THRESHOLDS}
/>
</div>
<div className='text-accent text-sm text-center'>
{regionTier < REGION_UNLOCK_THRESHOLDS.length - 1 ? (
<span>{`Next region unlocked at ${REGION_UNLOCK_THRESHOLDS[regionTier + 1]} tasks (${
REGION_UNLOCK_THRESHOLDS[regionTier + 1] - taskStats.tasks.complete.total
} remaining)`}</span>
) : (
'All regions unlocked'
)}
</div>
</div>
</div>
</div>
<Separator />
<div className='flex xl:flex-row flex-col justify-around w-full bg-secondary-alt xl:bg-primary'>
{isSmViewport && showSidebar && (
<div className='mt-3 bg-hover cursor-pointer' onClick={() => setShowSidebar(!showSidebar)}>
<span className='icon-xl align-middle'>keyboard_double_arrow_up</span>
<span className='text-sm italic ml-1'>Hide filters</span>
</div>
)}
{showSidebar && (
<div className='basis-[23%] flex flex-col gap-3 pl-2'>
<TaskFilters history={history} />
<Separator />
{!readonly && <TaskGenerator />}
</div>
)}
<div className='mt-3 mb-3 bg-hover cursor-pointer' onClick={() => setShowSidebar(!showSidebar)}>
{isXlViewport ? (
<span className='icon-xl align-middle'>
{showSidebar ? 'keyboard_double_arrow_left' : 'keyboard_double_arrow_right'}
</span>
) : (
<>
<span className='icon-xl align-middle'>
{showSidebar ? 'keyboard_double_arrow_up' : 'keyboard_double_arrow_down'}
</span>
<span className='text-sm italic ml-1'>{showSidebar ? 'Hide filters' : 'Show filters'}</span>
</>
)}
</div>
<div className='basis-3/4 grow flex flex-col xl:ml-1 bg-primary'>
<div className='border-t xl:border-l xl:border-t-0 pt-2 xl:pt-[0] border-subdued grow xl:mt-3'>
<TaskTable history={history} readonly={readonly} taskState={taskState} />
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,48 @@
import React from 'react';
import CharacterPanel from './CharacterPanel';
import TabbedCard from '../components/common/TabbedCard';
import PageWrapper from '../components/PageWrapper';
import TasksPanel from './TasksPanel';
import useQueryString from '../hooks/useQueryString';
import QuestsPanel from './QuestsPanel';
import images from '../assets/images';
import DiariesPanel from './DiariesPanel';
import useBreakpoint, { MEDIA_QUERIES, MODE } from '../hooks/useBreakpoint';
import useFetchHiscoresOnLoad from '../hooks/useFetchHiscoresOnLoad';
export default function Tracker() {
const [selectedTab, onSetSelectedTab] = useQueryString('tab');
const isXsOrSmallerViewport = useBreakpoint(MEDIA_QUERIES.XS, MODE.LESS_OR_EQ);
useFetchHiscoresOnLoad();
return (
<PageWrapper>
<TabbedCard defaultActiveTab={selectedTab} setTabCallback={onSetSelectedTab}>
<TabbedCard.Tab
id='character'
label={isXsOrSmallerViewport ? undefined : 'Character'}
icon={images['tab-character.png']}
>
<CharacterPanel />
</TabbedCard.Tab>
<TabbedCard.Tab id='tasks' label={isXsOrSmallerViewport ? undefined : 'Tasks'} icon={images['tab-tasks.png']}>
<TasksPanel />
</TabbedCard.Tab>
<TabbedCard.Tab
id='quests'
label={isXsOrSmallerViewport ? undefined : 'Quests'}
icon={images['tab-quests.png']}
>
<QuestsPanel />
</TabbedCard.Tab>
<TabbedCard.Tab
id='diaries'
label={isXsOrSmallerViewport ? undefined : 'Diaries'}
icon={images['tab-diaries.png']}
>
<DiariesPanel />
</TabbedCard.Tab>
</TabbedCard>
</PageWrapper>
);
}

View File

@@ -0,0 +1,45 @@
import React, { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import PageWrapper from '../components/PageWrapper';
import TasksPanel from './TasksPanel';
import Card from '../components/common/Card';
import Banner from '../components/common/Banner';
import { getUserByRsn } from '../client/user-data-client';
import Spinner from '../components/common/Spinner';
export default function ViewCharacter() {
const { character } = useParams();
const [taskState, setTaskState] = useState();
const [error, setError] = useState();
useEffect(() => {
getUserByRsn(character).then(res => {
if (res.success) {
setTaskState(JSON.parse(res.value[`tasks_${character}`].S));
} else {
setError('Unable to load tasks for character.');
}
});
}, []);
return (
<PageWrapper>
<Banner className='mb-4 text-center'>
Viewing tasks for character <span className='heading-accent-md'>{character}</span>
</Banner>
<Card>
<Card.Body>
{taskState ? (
<TasksPanel readonly taskState={taskState} />
) : (
<div className='flex flex-col gap-4 items-center justify-center w-full text-center'>
<Spinner />
{error && <span className='text-error'>{error}</span>}
</div>
)}
</Card.Body>
</Card>
</PageWrapper>
);
}