Skip to content

Commit

Permalink
feat: add log book overview chart (#921)
Browse files Browse the repository at this point in the history
* feat: introduce climb log overview chart
  • Loading branch information
vnugent authored Jul 18, 2023
1 parent d7ca843 commit 75b5e48
Show file tree
Hide file tree
Showing 10 changed files with 386 additions and 129 deletions.
6 changes: 0 additions & 6 deletions .github/workflows/nodejs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,6 @@ jobs:
run: yarn test
if: ${{ github.event_name == 'pull_request' }}

- name: Run unit & e2e tests
run: yarn test-all
env:
SIRV_CLIENT_SECRET_RW: ${{ secrets.SIRV_CLIENT_SECRET_RW }}
if: ${{ github.event_name != 'pull_request' }}

- name: Build project
run: yarn build
if: ${{ github.event_name != 'pull_request' }}
Expand Down
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,8 @@
"react-swipeable": "^7.0.0",
"react-toastify": "^9.1.1",
"react-use": "^17.4.0",
"recharts": "^2.1.9",
"recharts": "^2.7.2",
"simple-statistics": "^7.8.3",
"swr": "^2.1.5",
"tailwindcss-radix": "^2.5.0",
"typesense": "^1.2.1",
Expand All @@ -88,8 +89,7 @@
"local": "NEXT_PUBLIC_API_SERVER=http://localhost:4000 node ./src/server.js ",
"develop": "next dev",
"start": "next start",
"test": "jest --testPathIgnorePatterns=/tests/e2e/ --",
"test-all": "jest",
"test": "jest",
"prepare": "husky install"
},
"devDependencies": {
Expand Down Expand Up @@ -142,4 +142,4 @@
"engines": {
"node": "18"
}
}
}
198 changes: 198 additions & 0 deletions src/components/logbook/OverviewChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import {
ResponsiveContainer, ComposedChart, CartesianGrid, XAxis, YAxis, Bar, Line,
Tooltip, LineProps, Brush
} from 'recharts'
import { groupBy } from 'underscore'
import { lastDayOfMonth, format } from 'date-fns'
import { getScale } from '@openbeta/sandbag'
import { linearRegression, linearRegressionLine, minSorted, maxSorted, medianSorted } from 'simple-statistics'

import { TickType } from '../../js/types'

export interface OverviewChartProps {
tickList: TickType[]
}

/**
* Proof of concept chart showing climbs aggregated by a time interval
*/
const OverviewChart: React.FC<OverviewChartProps> = ({ tickList }) => {
/**
* Assume grades are YDS or Vscale for now since we don't store
* grade context with ticks, nor do we have a way to get score
* without knowing the grade system
*/
const ydsScale = getScale('yds')
const vScale = getScale('vscale')

const agg = groupBy(tickList, getYearMonthFromDate)

const xyRegressionData: number[][] = []

const chartData: ChartDataPayloadProps[] = Object.entries(agg).reverse().map(value => {
const x = parseInt(value[0])
const gradeScores = value[1].reduce<number[]>((acc, curr) => {
let score = ydsScale?.getScore(curr.grade)?.[0] as number ?? -1

if (score < 0) {
score = vScale?.getScore(curr.grade)[0] as number ?? -1
}
if (score > 0) {
acc.push(score)
}
return acc
}, [])

const gradeScoresSorted = gradeScores.sort((a, b) => a - b)
let medianScore = -1
if (gradeScores.length > 0) {
medianScore = medianSorted(gradeScoresSorted)
xyRegressionData.push([x, medianScore])
}
return {
date: x,
total: value[1].length,
score: medianScore,
low: minSorted(gradeScoresSorted),
high: maxSorted(gradeScoresSorted)
}
})

const linearFn = linearRegressionLine(linearRegression(xyRegressionData))

const chartData2 = chartData.reduce<ChartDataPayloadProps[]>((acc, curr) => {
if (curr.score > 0) {
acc.push({
...curr,
linearReg: linearFn(curr.date)
})
}
return acc
}, [])

return (
<div className='w-full'>
<ResponsiveContainer height={350}>
<ComposedChart
data={chartData2}
syncId='overviewChart'
>
<CartesianGrid stroke='#f5f5f5' />
<YAxis
yAxisId='score' stroke='rgb(15 23 42)' tickFormatter={(value) => {
return parseInt(value) <= 0 ? ' ' : value
}}
/>

<Line
yAxisId='score' type='monotone' dataKey='score' stroke='none' dot={<CustomizeMedianDot />}
isAnimationActive={false}
/>

<Line
yAxisId='score' type='monotone' dataKey='low' stroke='rgb(15 23 42)'
opacity={0.5} dot={{
display: 'none'
}}
isAnimationActive={false}
/>
<Line
yAxisId='score' type='natural' dataKey='high' stroke='rgb(15 23 42)'
opacity={0.5}
dot={{
display: 'none'
}}
isAnimationActive={false}
/>

<Line
yAxisId='score' type='monotone' dataKey='linearReg'
stroke='rgb(239 68 68)'
strokeWidth={2}
strokeDasharray='2, 5'
dot={{
display: 'none'
}}
isAnimationActive={false}
/>

<XAxis dataKey='date' tick={{ fontSize: '10' }} tickFormatter={xAxisFormatter} />

<Bar
yAxisId='total' dataKey='total' fill='rgb(7 89 133)' opacity={0.15} spacing={5}
/>

<YAxis
yAxisId='total' orientation='right' fill='rgb(7 89 133)' opacity={0.45} type='number'
domain={[0, 'dataMax + 20']}
/>

<Tooltip offset={30} content={<CustomTooltip />} />

<Brush
dataKey='date' height={30} stroke='#8884d8' tickFormatter={(value) => {
return format(value, 'MMM yyyy')
}}
/>

</ComposedChart>
</ResponsiveContainer>
</div>
)
}

export default OverviewChart

const getYearMonthFromDate = (tick: TickType): number => lastDayOfMonth(tick.dateClimbed).getTime()

const xAxisFormatter = (data: any): any => {
return format(data, 'MMM yy')
}

/**
* Make median score looks like a candle stick
*/
const CustomizeMedianDot: React.FC<LineProps & { payload?: ChartDataPayloadProps}> = (props) => {
const { cx, cy, payload } = props
if (cx == null || cy == null || payload == null) return null
const lengthOffset = payload.total * 1.2
return (
<>
<line
x1={cx} y1={cy as number - lengthOffset} x2={cx} y2={cy as number + lengthOffset}
stroke='rgb(190 24 93)'
strokeWidth={6}
strokeLinecap='round'
/>
<line
x1={cx as number - 5} y1={cy} x2={cx as number + 5} y2={cy}
stroke='rgb(190 24 93)'
strokeWidth={2}
/>
</>
)
}

const CustomTooltip: React.FC<any> = ({ active, payload, label }) => {
if (active === true && payload != null && payload.length > 0) {
return (
<div className='bg-info p-4 rounded-btn'>
<div>Total climbs: <span className='font-semibold'>{payload[4].value}</span></div>
<div>Median: {payload[0].value}</div>
<div>Low: {payload[1].value}</div>
<div>High: {payload[2].value}</div>
</div>
)
}

return null
}

interface ChartDataPayloadProps {
date: number
total: number
score: number
low: number
high: number
linearReg?: number
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ exports[`renders correctly 1`] = `
"height": "100%",
"maxHeight": undefined,
"minHeight": undefined,
"minWidth": undefined,
"minWidth": 0,
"width": "100%",
}
}
Expand Down
6 changes: 5 additions & 1 deletion src/components/users/ImportFromMtnProj.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Fragment, useEffect, useState } from 'react'
import { useRouter } from 'next/router'
import { Transition } from '@headlessui/react'
import { FolderArrowDownIcon, XMarkIcon } from '@heroicons/react/24/outline'
import { useMutation } from '@apollo/client'
Expand All @@ -10,6 +11,7 @@ import { MUTATION_IMPORT_TICKS } from '../../js/graphql/gql/fragments'

interface Props {
isButton: boolean
username: string
}
// regex pattern to validate mountain project input
const pattern = /^https:\/\/www.mountainproject.com\/user\/\d{9}\/[a-zA-Z-]*/
Expand All @@ -22,7 +24,8 @@ const pattern = /^https:\/\/www.mountainproject.com\/user\/\d{9}\/[a-zA-Z-]*/
* if the isButton prop is false, the component will be rendered as a modal
* @returns JSX element
*/
export function ImportFromMtnProj ({ isButton }: Props): JSX.Element {
export function ImportFromMtnProj ({ isButton, username }: Props): JSX.Element {
const router = useRouter()
const [mpUID, setMPUID] = useState('')
const session = useSession()
const [show, setShow] = useState<boolean>(false)
Expand Down Expand Up @@ -75,6 +78,7 @@ export function ImportFromMtnProj ({ isButton }: Props): JSX.Element {

const ticksCount: number = ticks?.length ?? 0
toast.info(`${ticksCount} ticks have been imported!`)
await router.replace(`/u2/${username}`)
} else {
setErrors(['Sorry, something went wrong. Please try again later'])
}
Expand Down
2 changes: 1 addition & 1 deletion src/components/users/PublicProfile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export default function PublicProfile ({ userProfile: initialUserProfile }: Publ
<div className='btn btn-info btn-xs'> View ticks</div>
</a>
</Link>}
{userProfile != null && isAuthorized && <ImportFromMtnProj isButton />}
{username != null && isAuthorized && <ImportFromMtnProj isButton username={username} />}
{userProfile != null && <ChangeUsernameLink userUuid={userProfile?.userUuid} />}
{userProfile != null && <APIKeyCopy userUuid={userProfile.userUuid} />}
</div>
Expand Down
2 changes: 1 addition & 1 deletion src/js/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,7 @@ export interface TickType {
climbId: string
style: string
attemptType: string
dateClimbed: Date
dateClimbed: number
grade: string
source: string
}
Expand Down
5 changes: 0 additions & 5 deletions src/pages/climbs/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import RouteTypeChips from '../../components/ui/RouteTypeChips'
import PhotoMontage, { Skeleton as PhotoMontageSkeleton } from '../../components/media/PhotoMontage'
import { useClimbSeo } from '../../js/hooks/seo/useClimbSeo'
import TickButton from '../../components/users/TickButton'
import { ImportFromMtnProj } from '../../components/users/ImportFromMtnProj'
import EditModeToggle from '../../components/editor/EditModeToggle'
import { AREA_NAME_FORM_VALIDATION_RULES } from '../../components/edit/EditAreaForm'
import useUpdateClimbsCmd from '../../js/hooks/useUpdateClimbsCmd'
Expand Down Expand Up @@ -288,10 +287,6 @@ const Body = ({ climb, leftClimb, rightClimb }: ClimbPageProps): JSX.Element =>
</div>}

</div>

<div className='pl-1'>
<ImportFromMtnProj isButton={false} />
</div>
</div>

<div className='area-climb-page-summary-right'>
Expand Down
37 changes: 29 additions & 8 deletions src/pages/u2/[...slug].tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { NextPage, GetStaticProps } from 'next'
import React from 'react'

import { NextPage, GetStaticProps } from 'next'
import dynamic from 'next/dynamic'
import Link from 'next/link'
import { getTicksByUser } from '../../js/graphql/api'
import { TickType } from '../../js/types'
import { OverviewChartProps } from '../../components/logbook/OverviewChart'
import ImportFromMtnProj from '../../components/users/ImportFromMtnProj'
import Layout from '../../components/layout'

interface TicksIndexPageProps {
username: string
Expand All @@ -18,13 +23,23 @@ interface TicksIndexPageProps {
*/
const Index: NextPage<TicksIndexPageProps> = ({ username, ticks }) => {
return (
<section className='max-w-lg mx-auto w-full px-4 py-8'>
<h1>{username}</h1>
<div>
{ticks?.map(Tick)}
{ticks?.length === 0 && <div>No ticks</div>}
</div>
</section>
<Layout
contentContainerClass='content-default with-standard-y-margin'
showFilterBar={false}
>
<section className='w-full pt-6'>
<DynamicOverviewChart tickList={ticks} />
</section>
<section className='max-w-lg mx-auto w-full px-4 py-8'>
<h2>{username}</h2>
<ImportFromMtnProj isButton username={username} />
<h3 className='py-4'>Log book</h3>
<div>
{ticks?.map(Tick)}
{ticks?.length === 0 && <div>No ticks</div>}
</div>
</section>
</Layout>
)
}

Expand Down Expand Up @@ -69,3 +84,9 @@ export const getStaticProps: GetStaticProps<TicksIndexPageProps, {slug: string[]
return { notFound: true }
}
}

const DynamicOverviewChart = dynamic<OverviewChartProps>(
async () =>
await import('../../components/logbook/OverviewChart').then(
module => module.default), { ssr: false }
)
Loading

1 comment on commit 75b5e48

@vercel
Copy link

@vercel vercel bot commented on 75b5e48 Jul 18, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.