Skip to content

Commit

Permalink
feat: allow null for tick fields & add markdown for tick logic
Browse files Browse the repository at this point in the history
  • Loading branch information
glassbead0 authored and vnugent committed Feb 5, 2025
1 parent 902560f commit 30f1fbc
Show file tree
Hide file tree
Showing 5 changed files with 79 additions and 77 deletions.
38 changes: 20 additions & 18 deletions documentation/tick_logic.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,29 +16,29 @@ Let's get to the technical details. There are 3 layers to this architecturally i
* `Tick.style`
* `Tick.attemptType`

(Here)[https://github.com/OpenBeta/openbeta-graphql/blob/develop/src/graphql/schema/Climb.gql#L115] are all the possible values for `Climb.type`, as defined in the [limb Schema](https://github.com/OpenBeta/openbeta-graphql/blob/develop/src/graphql/schema/Climb.gql#L115), and style and attempts types defined in the [TickSchema](https://github.com/OpenBeta/openbeta-graphql/blob/develop/src/db/TickTypes.ts).
Here are all the possible values for `Climb.type` (also called discipline), as defined in the [Climb Schema](https://github.com/OpenBeta/openbeta-graphql/blob/develop/src/graphql/schema/Climb.gql#L115), and Tick style and attemptsTypes defined in the [TickSchema](https://github.com/OpenBeta/openbeta-graphql/blob/develop/src/db/TickTypes.ts).

| Climb.type | Tick.style | Tick.attemptType |
|---------------|------------|------------------|
| trad | Lead | Onsight |
| sport | Follow | Flash |
| bouldering | TR | Redpoint |
| deepwatersolo | Solo | Pinkpoint |
| snow | Aid | Send |
| ice | Boulder | Attempt |
| aid | Frenchfree | |
| tr | |
| alpine | |
| mixed | |
| trad | Lead | Onsight |
| sport | Follow | Flash |
| bouldering | TR | Redpoint |
| deepwatersolo | Solo | Pinkpoint |
| snow | Aid | Send |
| ice | Boulder | Attempt |
| aid | | Frenchfree |
| tr | | |
| alpine | | |
| mixed | | |


See the [Wikipedia Glossary of Climbing Terms](https://en.wikipedia.org/wiki/Glossary_of_climbing_terms) for common definitions of all these terms.

Given the 10 climb types, 6 styles, and 7 attempt types, there are 10*6*7=**420** diffent ways to "tick" a route.
Given the 10 climb types, 6 styles, and 7 attempt types, there are `10*6*7=`**420** diffent ways to "tick" a route. *(Thats not even accounting for the fact that a route can be multiple disciplines, eg: boulder & TR, or sport & deepwatersolo. If you really want to get nerdy: with the `2^10=1024` possible discipline combinations, there are a whopping `1024*6*7=`**43,008** ways to tick a route!)*

Here's a Hierarchical way to restrict values:
## Here's a Hierarchical way to restrict values:

## Climb type -> Tick Style
### Climb type -> Tick Style

| Climb Type | logical description | Tick Style Options |
|-------------------|---------------------|-------------------- |
Expand All @@ -48,15 +48,17 @@ Here's a Hierarchical way to restrict values:
| 'deepwatersolo' or leadable or aidable or topropeable | soloable | Solo |
| bouldering | boulderable | Boulder |

## Tick Style -> Tick Attempt Type

Since a route can have multiple disciplines, these options are composable. eg: a route marked as 'trad, aid', is both 'leadable' and 'aidable'. A route that is 'boulder, tr', is both 'boulderable' and 'topropeable'

### Tick Style -> Tick Attempt Type

| Tick Style | Attempt Type options |
|------------|----------------------|
| 'Lead' | 'Onsight', 'Flash', 'Redpoint', 'Pinkpoint', 'Attempt', 'Frenchfree' |
| 'Follow' or 'TR' | 'Send', 'Attempt', 'Frenchfree' |
| 'Follow', 'TR' or 'Aid | 'Send', 'Attempt' |
| 'Solo' | 'Onsight', 'Flash', 'Redpoint', 'Attempt' |
| 'Boulder' | 'Flash', 'Send', 'Attempt' |
| 'Aid' | 'Send', 'Attempt' |

## A few justifications

Expand All @@ -65,7 +67,7 @@ Here's a Hierarchical way to restrict values:
* OB does not use the term "Fell/Hung" for roped climbs, and instead normalizes it to "Attempt", just like boulders. Importing routes from MP will convert "Fell/Hung" to "Attempt"
* While 'Frenchfree' and 'Aid' could be considered synomonous, some climbers may want to distinguish, for example, a multipitch route where one pitch was intentionally 'French freed' (Time Wave Zero being a common example), which is distinctly different in character than, eg: aiding the Nose on El Cap.
* Eventually, it might be cool to allow ticks for individual pitches, but that is not supported right now.
* Given the 420 possible combination, no simple logical system will perfectly capture every edge case.
* Given the 43,008 possible combinations, no simple logical system will perfectly capture every edge case.


## Importing from Mountain Project
Expand Down
4 changes: 2 additions & 2 deletions src/db/TickSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ export const TickSchema = new Schema<TickType>({
notes: { type: Schema.Types.String, required: false },
climbId: { type: Schema.Types.String, required: true, index: true },
userId: { type: Schema.Types.String, required: true, index: true },
style: { type: Schema.Types.String, enum: ['Lead', 'Solo', 'TR', 'Follow', 'Aid', 'Boulder', null], required: false },
attemptType: { type: Schema.Types.String, enum: ['Onsight', 'Flash', 'Pinkpoint', 'Frenchfree', 'Redpoint', 'Send', 'Attempt', null], required: false, index: true },
style: { type: Schema.Types.String, enum: ['Lead', 'Solo', 'TR', 'Follow', 'Aid', 'Boulder'], required: false },
attemptType: { type: Schema.Types.String, enum: ['Onsight', 'Flash', 'Pinkpoint', 'Frenchfree', 'Redpoint', 'Send', 'Attempt'], required: false, index: true },
dateClimbed: { type: Schema.Types.Date },
grade: { type: Schema.Types.String, required: false, index: true },
// Bear in mind that these enum types must be kept in sync with the TickSource enum
Expand Down
1 change: 0 additions & 1 deletion src/graphql/schema/Tick.gql
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,6 @@ enum TickAttemptType {
Send
"Redpoint"
Redpoint

}

enum TickStyle {
Expand Down
96 changes: 52 additions & 44 deletions src/model/TickDataSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,51 +86,59 @@ export default class TickDataSource extends MongoDataSource<TickType> {
if (climb == null) {
throw new Error('Climb not found')
}
// Getting the climb singular type to verifiy, some climbs have multiple types such as the heart route on elcap (13b/v10)
const isDWSOnly =
(climb.type.deepwatersolo === true) &&
Object.keys(climb.type).every(
key => key === 'deepwatersolo' || climb.type[key] === false)

const isBoulderingOnly =
(climb.type.bouldering === true) &&
Object.keys(climb.type).every(
key => key === 'bouldering' || climb.type[key] === false)

const isTROnly =
(climb.type.tr === true) &&
Object.keys(climb.type).every(
key => key === 'tr' || climb.type[key] === false)

const isAidOnly =
(climb.type.aid === true) &&
Object.keys(climb.type).every(
key => key === 'aid' || climb.type[key] === false)

const isTradSportAlpineIceMixedAid =
['trad', 'sport', 'alpine', 'ice', 'mixed', 'aid'].some(
type => climb.type[type] === true)

const tickStyle = tick.style ?? 'null' // Provide a default value if tick.style is undefined

// Tick validation logic is complicated. see [tick_logic.md](https://github.com/OpenBeta/openbeta-graphql/blob/develop/documentation/tick_logic.md).

const tickStyle = tick.style ?? 'null' // Provide a default value if tick.style is undefined. This 'null' string is not saved in the db, but used for easy validation.
const attemptType = tick.attemptType ?? 'null' // Provide a default value if tick.attempy is undefined
if (isDWSOnly || isBoulderingOnly) { // bouldering and dws can only have attempt types: 'Send', 'Flash', 'Attempt', 'Onsight' and should have no sytle
if ((['Lead', 'Solo', 'Tr', 'Follow', 'Aid'].includes(tickStyle)) || ['Pinkpoint', 'Frenchfree', 'Redpoint'].includes(attemptType)) {
throw new Error('Invalid attempt type or style for DWS/Bouldering')
}
} else if (isTROnly) { // TopRope can only have attempt types: 'Send', 'Flash', 'Attempt', 'Onsight' and styles: 'TR'
if (!['TR', 'null'].includes(tickStyle) || ['Pinkpoint', 'Frenchfree', 'Redpoint'].includes(attemptType)) {
throw new Error('Invalid attempt type or style for TR only')
}
} else if (isAidOnly) { // Aid can only have attempt types: 'Send', 'Attempt' and styles: 'Aid', 'Follow'
if (!['Aid', 'Follow', 'null'].includes(tickStyle) || ['Onsight', 'Flash', 'Pinkpoint', 'Frenchfree'].includes(attemptType)) {
throw new Error('Invalid attempt type or style for Aid only')
}
} else if (isTradSportAlpineIceMixedAid) { // roped climbs that aren't lead must have attempt types: 'Send', 'Flash', 'Attempt', 'Onsight'
if (['Solo', 'TR', 'Follow'].includes(tickStyle) && ['Pinkpoint', 'Frenchfree', 'Redpoint'].includes(attemptType)) {
throw new Error('Invalid attempt type for Solo/TR/Follow style')
}
} else {
throw new Error('Invalid climb type')

const leadable = ['trad', 'sport', 'snow', 'ice', 'mixed', 'alpine'].some(type => climb.type[type] === true)
const topropeable = (climb.type.tr === true) || leadable
const aidable = climb.type.aid === true
const boulderable = climb.type.bouldering === true
const soloable = (climb.type.deepwatersolo === true) || leadable || aidable || (topropeable && !boulderable)

// Validate tick style for each climb type
if (!leadable && (['Lead', 'Follow'].includes(tickStyle))) {
throw new Error(`Invalid style ${tickStyle} for climb type`)
}
if (!topropeable && (tickStyle === 'TR')) {
throw new Error(`Invalid style ${tickStyle} for climb type`)
}
if (!aidable && (tickStyle === 'Aid')) {
throw new Error(`Invalid style ${tickStyle} for climb type`)
}
if (!boulderable && (tickStyle === 'Boulder')) {
throw new Error(`Invalid style ${tickStyle} for climb type`)
}
if (!soloable && (tickStyle === 'Solo')) {
throw new Error(`Invalid style ${tickStyle} for climb type`)
}

// validate attempt type for each tick style
switch (tickStyle) {
case 'Lead':
if (!['Onsight', 'Flash', 'Redpoint', 'Pinkpoint', 'Attempt', 'Frenchfree', 'null'].includes(attemptType)) {
throw new Error(`Invalid attempt type ${attemptType} for Lead style`)
}
break
case 'Solo':
if (!['Onsight', 'Flash', 'Redpoint', 'Attempt', 'null'].includes(attemptType)) {
throw new Error(`Invalid attempt type ${attemptType} for Solo style`)
}
break
case 'Boulder':
if (!['Flash', 'Send', 'Attempt', 'null'].includes(attemptType)) {
throw new Error(`Invalid attempt type ${attemptType} for Boulder style`)
}
break
case 'TR':
case 'Follow':
case 'Aid':
if (!['Send', 'Attempt', 'null'].includes(attemptType)) {
throw new Error(`Invalid attempt type ${attemptType} for TR/Follow/Aid style`)
}
break
}
}

Expand Down
17 changes: 5 additions & 12 deletions src/model/__tests__/tickValidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ const toTestBoulder: TickInput = {
notes: 'wet!',
climbId: 'tbd',
userId: userId.toUUID().toString(),
style: 'Boulder',
attemptType: 'Flash',
dateClimbed: new Date('2012-10-15'),
grade: 'v4',
Expand Down Expand Up @@ -168,21 +169,13 @@ describe('Tick Validation', () => {
await expect(ticks.addTick(dwsTick)).resolves.not.toThrow()
})

it('should throw error for invalid attempt type for deep water solo climb', async () => {
const invalidDwsTick: TickInput = {
...toTestDWS,
attemptType: 'Pinkpoint'
}
await expect(ticks.addTick(invalidDwsTick)).rejects.toThrow('Invalid attempt type or style for DWS/Bouldering')
})

it('should throw error for invalid style for deep water solo climb', async () => {
const invalidDwsTick: TickInput = {
...toTestDWS,
style: 'Lead',
attemptType: 'Send'
}
await expect(ticks.addTick(invalidDwsTick)).rejects.toThrow('Invalid attempt type or style for DWS/Bouldering')
await expect(ticks.addTick(invalidDwsTick)).rejects.toThrow('Invalid style Lead for climb type')
})

it('should validate tick for top rope climb', async () => {
Expand All @@ -194,7 +187,7 @@ describe('Tick Validation', () => {
...toTestTR,
attemptType: 'Pinkpoint'
}
await expect(ticks.addTick(invalidTrTick)).rejects.toThrow('Invalid attempt type or style for TR only')
await expect(ticks.addTick(invalidTrTick)).rejects.toThrow('Invalid attempt type Pinkpoint for TR/Follow/Aid style')
})

it('should validate tick for aid climb', async () => {
Expand All @@ -206,7 +199,7 @@ describe('Tick Validation', () => {
...toTestAid,
attemptType: 'Flash'
}
await expect(ticks.addTick(invalidAidTick)).rejects.toThrow('Invalid attempt type or style for Aid only')
await expect(ticks.addTick(invalidAidTick)).rejects.toThrow('Invalid attempt type Flash for TR/Follow/Aid style')
})

it('should throw error for invalid style for aid climb', async () => {
Expand All @@ -215,7 +208,7 @@ describe('Tick Validation', () => {
style: 'Lead',
attemptType: 'Send'
}
await expect(ticks.addTick(invalidAidTick)).rejects.toThrow('Invalid attempt type or style for Aid only')
await expect(ticks.addTick(invalidAidTick)).rejects.toThrow('Invalid style Lead for climb type')
})

it('should validate tick with no attempt type', async () => {
Expand Down

0 comments on commit 30f1fbc

Please sign in to comment.