Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
a85d93b
feat: Enable Moonboard climb saving to PostgreSQL database
marcodejongh Jan 23, 2026
e2a9cbf
fix: Enable Moonboard climb preview rendering in list view
marcodejongh Jan 23, 2026
6f218be
fix: Add missing board_type filters to backend unified table queries
marcodejongh Jan 23, 2026
fff93db
feat: Consolidate climb creation pages with grade/angle support
marcodejongh Jan 24, 2026
47b5a6f
fix: Fetch grade data from climb_stats for MoonBoard list view
marcodejongh Jan 24, 2026
67e7469
fix: Format MoonBoard grades correctly for ClimbTitle display
marcodejongh Jan 24, 2026
318c787
fix: Round difficultyId when looking up grade (handle doublePrecision)
marcodejongh Jan 24, 2026
2311e23
debug: Add logging for MoonBoard grade save and list
marcodejongh Jan 24, 2026
4229cf5
chore: Remove debug logging for MoonBoard grade save/list
marcodejongh Jan 24, 2026
e6dfe10
fix: Save setter name and grade from MoonBoard bulk import
marcodejongh Jan 24, 2026
c3cfbe8
feat: Upload MoonBoard OCR images to S3 for test data
marcodejongh Jan 24, 2026
a22f602
feat: Add MoonBoard support to board validation and profile stats
marcodejongh Jan 24, 2026
69e5ea5
fix: Use MoonBoard-specific board details for MoonBoard routes
marcodejongh Jan 24, 2026
3378dd6
fix: Correct MoonBoard hold state colors (green=start, red=finish)
marcodejongh Jan 24, 2026
2b37eda
fix: Add MoonBoard layout display names to profile page and activity …
marcodejongh Jan 24, 2026
dc9848d
refactor: Consolidate MoonBoard color definitions to single source
marcodejongh Jan 24, 2026
e28853f
refactor: Extract shared board utils and improve MoonBoard code quality
marcodejongh Jan 24, 2026
9781b79
fix: Enable MoonBoard board preview in profile activity feed
marcodejongh Jan 24, 2026
d308929
fix: Improve null safety and cleanup unused imports
marcodejongh Jan 24, 2026
be80a6f
fix: Disable climb view page button for MoonBoard climbs
marcodejongh Jan 24, 2026
67eb1e4
fix: Add moonboard support to backend hold state and product size maps
marcodejongh Jan 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/backend/src/__tests__/graphql-resolvers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ describe('GraphQL Resolver Input Validation', () => {
climbUuid: 'test-uuid',
},
},
'Board name must be kilter or tension',
'Board name must be kilter, tension, or moonboard',
);
});
});
Expand Down Expand Up @@ -330,7 +330,7 @@ describe('GraphQL Resolver Input Validation', () => {
},
},
},
'Board name must be kilter, tension',
'Board name must be kilter, tension, or moonboard',
);
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@

// Base conditions for filtering climbs that don't reference the product sizes table
const baseConditions: SQL[] = [
eq(tables.climbs.boardType, params.board_name),
eq(tables.climbs.layoutId, params.layout_id),
eq(tables.climbs.isListed, true),
eq(tables.climbs.isDraft, false),
Expand All @@ -71,7 +72,7 @@
// Size-specific conditions using pre-fetched static edge values
// This eliminates the need for a JOIN on product_sizes in the main query
const sizeConditions: SQL[] = [
sql`${tables.climbs.edgeLeft} > ${sizeEdges.edgeLeft}`,

Check failure on line 75 in packages/backend/src/db/queries/climbs/create-climb-filters.ts

View workflow job for this annotation

GitHub Actions / test

src/__tests__/climb-queries.test.ts > Climb Query Functions > countClimbs > should respect filters in count

TypeError: Cannot read properties of null (reading 'edgeLeft') ❯ createClimbFilters src/db/queries/climbs/create-climb-filters.ts:75:49 ❯ countClimbs src/db/queries/climbs/count-climbs.ts:27:19 ❯ src/__tests__/climb-queries.test.ts:221:35

Check failure on line 75 in packages/backend/src/db/queries/climbs/create-climb-filters.ts

View workflow job for this annotation

GitHub Actions / test

src/__tests__/climb-queries.test.ts > Climb Query Functions > countClimbs > should return accurate total count

TypeError: Cannot read properties of null (reading 'edgeLeft') ❯ createClimbFilters src/db/queries/climbs/create-climb-filters.ts:75:49 ❯ countClimbs src/db/queries/climbs/count-climbs.ts:27:19 ❯ src/__tests__/climb-queries.test.ts:202:27
sql`${tables.climbs.edgeRight} < ${sizeEdges.edgeRight}`,
sql`${tables.climbs.edgeBottom} > ${sizeEdges.edgeBottom}`,
sql`${tables.climbs.edgeTop} < ${sizeEdges.edgeTop}`,
Expand Down Expand Up @@ -244,6 +245,7 @@
// For use in the subquery with left join
getClimbStatsJoinConditions: () => [
eq(tables.climbStats.climbUuid, tables.climbs.uuid),
eq(tables.climbStats.boardType, params.board_name),
eq(tables.climbStats.angle, params.angle),
],

Expand Down
15 changes: 12 additions & 3 deletions packages/backend/src/db/queries/climbs/get-climb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@
7: { name: 'FINISH', displayColor: '#FF0000', color: '#FF0000' },
8: { name: 'FOOT', displayColor: '#FF00FF', color: '#FF00FF' },
},
moonboard: {
42: { name: 'STARTING', color: '#00FF00', displayColor: '#44FF44' },
43: { name: 'HAND', color: '#0000FF', displayColor: '#4444FF' },
44: { name: 'FINISH', color: '#FF0000', displayColor: '#FF3333' },
},
};

// Warned hold states to avoid log spam
Expand Down Expand Up @@ -87,7 +92,7 @@
const tables = getBoardTables(params.board_name);

try {
const result = await db

Check failure on line 95 in packages/backend/src/db/queries/climbs/get-climb.ts

View workflow job for this annotation

GitHub Actions / test

src/__tests__/climb-queries.test.ts > Climb Query Functions > getClimbByUuid > should handle different board names

Error: Failed query: select "board_climbs"."uuid", "board_climbs"."setter_username", "board_climbs"."name", "board_climbs"."description", "board_climbs"."frames", COALESCE("board_climb_stats"."angle", $1), COALESCE("board_climb_stats"."ascensionist_count", 0), "board_difficulty_grades"."boulder_name", ROUND("board_climb_stats"."quality_average"::numeric, 2), ROUND("board_climb_stats"."difficulty_average"::numeric - "board_climb_stats"."display_difficulty"::numeric, 2), "board_climb_stats"."benchmark_difficulty" from "board_climbs" left join "board_climb_stats" on "board_climb_stats"."climb_uuid" = "board_climbs"."uuid" AND "board_climb_stats"."board_type" = $2 AND "board_climb_stats"."angle" = $3 left join "board_difficulty_grades" on "board_difficulty_grades"."difficulty" = ROUND("board_climb_stats"."display_difficulty"::numeric) AND "board_difficulty_grades"."board_type" = $4 where "board_climbs"."board_type" = $5 AND "board_climbs"."layout_id" = $6 AND "board_climbs"."uuid" = $7 AND "board_climbs"."frames_count" = 1 limit $8 params: 40,kilter,40,kilter,kilter,1,test-uuid,1 ❯ PostgresJsPreparedQuery.queryWithCache ../../node_modules/drizzle-orm/pg-core/session.js:41:15 ❯ ../../node_modules/drizzle-orm/postgres-js/session.js:37:20 ❯ getClimbByUuid src/db/queries/climbs/get-climb.ts:95:20 ❯ src/__tests__/climb-queries.test.ts:244:28 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { query: 'select "board_climbs"."uuid", "board_climbs"."setter_username", "board_climbs"."name", "board_climbs"."description", "board_climbs"."frames", COALESCE("board_climb_stats"."angle", $1), COALESCE("board_climb_stats"."ascensionist_count", 0), "board_difficulty_grades"."boulder_name", ROUND("board_climb_stats"."quality_average"::numeric, 2), ROUND("board_climb_stats"."difficulty_average"::numeric - "board_climb_stats"."display_difficulty"::numeric, 2), "board_climb_stats"."benchmark_difficulty" from "board_climbs" left join "board_climb_stats" on "board_climb_stats"."climb_uuid" = "board_climbs"."uuid"\n AND "board_climb_stats"."board_type" = $2\n AND "board_climb_stats"."angle" = $3 left join "board_difficulty_grades" on "board_difficulty_grades"."difficulty" = ROUND("board_climb_stats"."display_difficulty"::numeric)\n AND "board_difficulty_grades"."board_type" = $4 where "board_climbs"."board_type" = $5\n AND "board_climbs"."layout_id" = $6\n AND "board_climbs"."uuid" = $7\n AND "board_climbs"."frames_count" = 1 limit $8', params: [ 40, 'kilter', 40, 'kilter', 'kilter', 1, 'test-uuid', 1 ] } Caused by: Caused by: PostgresError: relation "board_climbs" does not exist ❯ ErrorResponse ../../node_modules/postgres/src/connection.js:794:26 ❯ handle ../../node_modules/postgres/src/connection.js:480:6 ❯ Socket.data ../../node_modules/postgres/src/connection.js:315:9 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { severity_local: 'ERROR', severity: 'ERROR', code: '42P01', position: '502', file: 'parse_relation.c', routine: 'parserOpenTable', query: 'select "board_climbs"."uuid", "board_climbs"."setter_username", "board_climbs"."name", "board_climbs"."description", "board_climbs"."frames", COALESCE("board_climb_stats"."angle", $1), COALESCE("board_climb_stats"."ascensionist_count", 0), "board_difficulty_grades"."boulder_name", ROUND("board_climb_stats"."quality_average"::numeric, 2), ROUND("board_climb_stats"."difficulty_average"::numeric - "board_climb_stats"."display_difficulty"::numeric, 2), "board_climb_stats"."benchmark_difficulty" from "board_climbs" left join "board_climb_stats" on "board_climb_stats"."climb_uuid" = "board_climbs"."uuid"\n AND "board_climb_stats"."board_type" = $2\n AND "board_climb_stats"."angle" = $3 left join "board_difficulty_grades" on "board_difficulty_grades"."difficulty" = ROUND("board_climb_stats"."display_difficulty"::numeric)\n AND "board_difficulty_grades"."b

Check failure on line 95 in packages/backend/src/db/queries/climbs/get-climb.ts

View workflow job for this annotation

GitHub Actions / test

src/__tests__/climb-queries.test.ts > Climb Query Functions > getClimbByUuid > should return null for non-existent UUID

Error: Failed query: select "board_climbs"."uuid", "board_climbs"."setter_username", "board_climbs"."name", "board_climbs"."description", "board_climbs"."frames", COALESCE("board_climb_stats"."angle", $1), COALESCE("board_climb_stats"."ascensionist_count", 0), "board_difficulty_grades"."boulder_name", ROUND("board_climb_stats"."quality_average"::numeric, 2), ROUND("board_climb_stats"."difficulty_average"::numeric - "board_climb_stats"."display_difficulty"::numeric, 2), "board_climb_stats"."benchmark_difficulty" from "board_climbs" left join "board_climb_stats" on "board_climb_stats"."climb_uuid" = "board_climbs"."uuid" AND "board_climb_stats"."board_type" = $2 AND "board_climb_stats"."angle" = $3 left join "board_difficulty_grades" on "board_difficulty_grades"."difficulty" = ROUND("board_climb_stats"."display_difficulty"::numeric) AND "board_difficulty_grades"."board_type" = $4 where "board_climbs"."board_type" = $5 AND "board_climbs"."layout_id" = $6 AND "board_climbs"."uuid" = $7 AND "board_climbs"."frames_count" = 1 limit $8 params: 40,kilter,40,kilter,kilter,1,non-existent-uuid-12345,1 ❯ PostgresJsPreparedQuery.queryWithCache ../../node_modules/drizzle-orm/pg-core/session.js:41:15 ❯ ../../node_modules/drizzle-orm/postgres-js/session.js:37:20 ❯ getClimbByUuid src/db/queries/climbs/get-climb.ts:95:20 ❯ src/__tests__/climb-queries.test.ts:231:22 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { query: 'select "board_climbs"."uuid", "board_climbs"."setter_username", "board_climbs"."name", "board_climbs"."description", "board_climbs"."frames", COALESCE("board_climb_stats"."angle", $1), COALESCE("board_climb_stats"."ascensionist_count", 0), "board_difficulty_grades"."boulder_name", ROUND("board_climb_stats"."quality_average"::numeric, 2), ROUND("board_climb_stats"."difficulty_average"::numeric - "board_climb_stats"."display_difficulty"::numeric, 2), "board_climb_stats"."benchmark_difficulty" from "board_climbs" left join "board_climb_stats" on "board_climb_stats"."climb_uuid" = "board_climbs"."uuid"\n AND "board_climb_stats"."board_type" = $2\n AND "board_climb_stats"."angle" = $3 left join "board_difficulty_grades" on "board_difficulty_grades"."difficulty" = ROUND("board_climb_stats"."display_difficulty"::numeric)\n AND "board_difficulty_grades"."board_type" = $4 where "board_climbs"."board_type" = $5\n AND "board_climbs"."layout_id" = $6\n AND "board_climbs"."uuid" = $7\n AND "board_climbs"."frames_count" = 1 limit $8', params: [ 40, 'kilter', 40, 'kilter', 'kilter', 1, 'non-existent-uuid-12345', 1 ] } Caused by: Caused by: PostgresError: relation "board_climbs" does not exist ❯ ErrorResponse ../../node_modules/postgres/src/connection.js:794:26 ❯ handle ../../node_modules/postgres/src/connection.js:480:6 ❯ Socket.data ../../node_modules/postgres/src/connection.js:315:9 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { severity_local: 'ERROR', severity: 'ERROR', code: '42P01', position: '502', file: 'parse_relation.c', routine: 'parserOpenTable', query: 'select "board_climbs"."uuid", "board_climbs"."setter_username", "board_climbs"."name", "board_climbs"."description", "board_climbs"."frames", COALESCE("board_climb_stats"."angle", $1), COALESCE("board_climb_stats"."ascensionist_count", 0), "board_difficulty_grades"."boulder_name", ROUND("board_climb_stats"."quality_average"::numeric, 2), ROUND("board_climb_stats"."difficulty_average"::numeric - "board_climb_stats"."display_difficulty"::numeric, 2), "board_climb_stats"."benchmark_difficulty" from "board_climbs" left join "board_climb_stats" on "board_climb_stats"."climb_uuid" = "board_climbs"."uuid"\n AND "board_climb_stats"."board_type" = $2\n AND "board_climb_stats"."angle" = $3 left join "board_difficulty_grades" on "board_difficulty_grades"."difficulty" = ROUND("board_climb_stats"."display_difficulty"::numeric)\n AND
.select({
uuid: tables.climbs.uuid,
setter_username: tables.climbs.setterUsername,
Expand All @@ -104,14 +109,18 @@
.from(tables.climbs)
.leftJoin(
tables.climbStats,
sql`${tables.climbStats.climbUuid} = ${tables.climbs.uuid} AND ${tables.climbStats.angle} = ${params.angle}`
sql`${tables.climbStats.climbUuid} = ${tables.climbs.uuid}
AND ${tables.climbStats.boardType} = ${params.board_name}
AND ${tables.climbStats.angle} = ${params.angle}`
)
.leftJoin(
tables.difficultyGrades,
sql`${tables.difficultyGrades.difficulty} = ROUND(${tables.climbStats.displayDifficulty}::numeric)`
sql`${tables.difficultyGrades.difficulty} = ROUND(${tables.climbStats.displayDifficulty}::numeric)
AND ${tables.difficultyGrades.boardType} = ${params.board_name}`
)
.where(
sql`${tables.climbs.layoutId} = ${params.layout_id}
sql`${tables.climbs.boardType} = ${params.board_name}
AND ${tables.climbs.layoutId} = ${params.layout_id}
AND ${tables.climbs.uuid} = ${params.climb_uuid}
AND ${tables.climbs.framesCount} = 1`
)
Expand Down
15 changes: 11 additions & 4 deletions packages/backend/src/db/queries/climbs/search-climbs.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { eq, desc, sql, and, getTableName } from 'drizzle-orm';
import { eq, desc, sql, and } from 'drizzle-orm';
import { db } from '../../client';
import { getBoardTables, type BoardName } from '../util/table-select';
import { createClimbFilters, type ClimbSearchParams, type ParsedBoardRouteParameters } from './create-climb-filters';
Expand Down Expand Up @@ -37,6 +37,11 @@ const HOLD_STATE_MAP: Record<
7: { name: 'FINISH', displayColor: '#FF0000', color: '#FF0000' },
8: { name: 'FOOT', displayColor: '#FF00FF', color: '#FF00FF' },
},
moonboard: {
42: { name: 'STARTING', color: '#00FF00', displayColor: '#44FF44' },
43: { name: 'HAND', color: '#0000FF', displayColor: '#4444FF' },
44: { name: 'FINISH', color: '#FF0000', displayColor: '#FF3333' },
},
};


Expand Down Expand Up @@ -92,7 +97,6 @@ export const searchClimbs = async (
const filters = createClimbFilters(tables, params, searchParams, sizeEdges, userId);

// Define sort columns with explicit SQL expressions where needed
const climbStatsTable = getTableName(boardClimbStats);
const allowedSortColumns: Record<string, ReturnType<typeof sql>> = {
ascents: sql`${tables.climbStats.ascensionistCount}`,
difficulty: sql`ROUND(${tables.climbStats.displayDifficulty}::numeric, 0)`,
Expand All @@ -101,7 +105,7 @@ export const searchClimbs = async (
// Popular: sum of ascents across ALL angles for this climb (using unified table)
popular: sql`(
SELECT COALESCE(SUM(cs.ascensionist_count), 0)
FROM ${sql.identifier(climbStatsTable)} cs
FROM ${boardClimbStats} cs
WHERE cs.board_type = ${params.board_name} AND cs.climb_uuid = ${tables.climbs.uuid}
)`,
};
Expand Down Expand Up @@ -148,7 +152,10 @@ export const searchClimbs = async (
.leftJoin(tables.climbStats, and(...filters.getClimbStatsJoinConditions()))
.leftJoin(
tables.difficultyGrades,
eq(tables.difficultyGrades.difficulty, sql`ROUND(${tables.climbStats.displayDifficulty}::numeric)`),
and(
eq(tables.difficultyGrades.difficulty, sql`ROUND(${tables.climbStats.displayDifficulty}::numeric)`),
eq(tables.difficultyGrades.boardType, params.board_name),
),
)
.where(and(...whereConditions))
.orderBy(
Expand Down
4 changes: 4 additions & 0 deletions packages/backend/src/db/queries/util/product-sizes-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ export const PRODUCT_SIZES: Record<BoardName, Record<number, ProductSizeData>> =
8: { id: 8, name: '12 high x 8 wide', description: '', edgeLeft: -44, edgeRight: 44, edgeBottom: 0, edgeTop: 144, productId: 5 },
9: { id: 9, name: '10 high x 8 wide', description: '', edgeLeft: -44, edgeRight: 44, edgeBottom: 0, edgeTop: 120, productId: 5 },
},
// MoonBoard has a single fixed size: 11 columns (A-K) x 18 rows
moonboard: {
1: { id: 1, name: 'Standard', description: '11x18 Grid', edgeLeft: 0, edgeRight: 11, edgeBottom: 0, edgeTop: 18, productId: 1 },
},
};

/**
Expand Down
12 changes: 8 additions & 4 deletions packages/backend/src/db/queries/util/table-select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@ import {
boardWalls,
boardTags,
} from '@boardsesh/db';
import type { BoardName } from '@boardsesh/shared-schema';
import { SUPPORTED_BOARDS } from '@boardsesh/shared-schema';

export type BoardName = 'kilter' | 'tension';
export type { BoardName };

// Unified tables - all queries should filter by board_type
export const UNIFIED_TABLES = {
Expand Down Expand Up @@ -58,21 +60,23 @@ export function getUnifiedTable<K extends keyof UnifiedTableSet>(
* @returns True if the board name is valid
*/
export function isValidBoardName(boardName: string): boardName is BoardName {
return boardName === 'kilter' || boardName === 'tension';
return SUPPORTED_BOARDS.includes(boardName as BoardName);
}

/**
* Extended board name type that includes moonboard for unified tables
* @deprecated BoardName now includes moonboard, use BoardName instead
*/
export type UnifiedBoardName = BoardName | 'moonboard';
export type UnifiedBoardName = BoardName;

/**
* Check if a board name is valid for unified tables (includes moonboard)
* @param boardName The name to check
* @returns True if the board name is valid for unified tables
* @deprecated Use isValidBoardName instead - it now includes moonboard
*/
export function isValidUnifiedBoardName(boardName: string): boardName is UnifiedBoardName {
return boardName === 'kilter' || boardName === 'tension' || boardName === 'moonboard';
return isValidBoardName(boardName);
}

// =============================================================================
Expand Down
11 changes: 6 additions & 5 deletions packages/backend/src/graphql/resolvers/climbs/queries.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ClimbSearchInput, ConnectionContext } from '@boardsesh/shared-schema';
import type { ClimbSearchInput, ConnectionContext, BoardName } from '@boardsesh/shared-schema';
import { SUPPORTED_BOARDS } from '@boardsesh/shared-schema';
import type { ClimbSearchParams, ParsedBoardRouteParameters } from '../../../db/queries/climbs/index';
import { getClimbByUuid } from '../../../db/queries/climbs/index';
import { getSizeEdges } from '../../../db/queries/util/product-sizes-data';
Expand All @@ -20,7 +21,7 @@ export const climbQueries = {

// Validate board name
if (!isValidBoardName(input.boardName)) {
throw new Error(`Invalid board name: ${input.boardName}. Must be 'kilter' or 'tension'`);
throw new Error(`Invalid board name: ${input.boardName}. Must be one of: ${SUPPORTED_BOARDS.join(', ')}`);
}

// Get size edges for filtering
Expand All @@ -34,7 +35,7 @@ export const climbQueries = {

// Build route parameters
const params: ParsedBoardRouteParameters = {
board_name: input.boardName as 'kilter' | 'tension',
board_name: input.boardName as BoardName,
layout_id: input.layoutId,
size_id: input.sizeId,
set_ids: setIds,
Expand Down Expand Up @@ -89,7 +90,7 @@ export const climbQueries = {
validateInput(BoardNameSchema, boardName, 'boardName');

if (!isValidBoardName(boardName)) {
throw new Error(`Invalid board name: ${boardName}. Must be 'kilter' or 'tension'`);
throw new Error(`Invalid board name: ${boardName}. Must be one of: ${SUPPORTED_BOARDS.join(', ')}`);
}

// Validate all parameters
Expand All @@ -101,7 +102,7 @@ export const climbQueries = {
if (DEBUG) console.log('[climb] Fetching:', { boardName, layoutId, sizeId, setIds, angle, climbUuid });

const climb = await getClimbByUuid({
board_name: boardName as 'kilter' | 'tension',
board_name: boardName as BoardName,
layout_id: layoutId,
size_id: sizeId,
angle,
Expand Down
24 changes: 19 additions & 5 deletions packages/backend/src/graphql/resolvers/playlists/queries.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { eq, and, inArray, desc, sql, or, isNull, asc } from 'drizzle-orm';
import type { ConnectionContext, Climb } from '@boardsesh/shared-schema';
import type { ConnectionContext, Climb, BoardName } from '@boardsesh/shared-schema';
import { SUPPORTED_BOARDS } from '@boardsesh/shared-schema';
import { db } from '../../../db/client';
import * as dbSchema from '@boardsesh/db/schema';
import { requireAuthenticated, validateInput } from '../shared/helpers';
Expand All @@ -17,7 +18,7 @@ import type { LitUpHoldsMap, HoldState } from '@boardsesh/shared-schema';
// Hold state mapping for converting frames string to lit up holds map
type HoldColor = string;
type HoldCode = number;
type BoardName = 'kilter' | 'tension';
// BoardName is imported from @boardsesh/shared-schema

const HOLD_STATE_MAP: Record<
BoardName,
Expand All @@ -43,6 +44,12 @@ const HOLD_STATE_MAP: Record<
7: { name: 'FINISH', displayColor: '#FF0000', color: '#FF0000' },
8: { name: 'FOOT', displayColor: '#FF00FF', color: '#FF00FF' },
},
// MoonBoard uses a different hold format - holds are identified by grid position
moonboard: {
1: { name: 'STARTING', color: '#00FF00' },
2: { name: 'HAND', color: '#0000FF' },
3: { name: 'FINISH', color: '#FF0000' },
},
};

function convertLitUpHoldsStringToMap(litUpHolds: string, board: BoardName): Record<number, LitUpHoldsMap> {
Expand Down Expand Up @@ -290,7 +297,7 @@ export const playlistQueries = {

// Validate board name
if (!isValidBoardName(boardName)) {
throw new Error(`Invalid board name: ${boardName}. Must be 'kilter' or 'tension'`);
throw new Error(`Invalid board name: ${boardName}. Must be one of: ${SUPPORTED_BOARDS.join(', ')}`);
}

// Get size edges for filtering
Expand Down Expand Up @@ -361,19 +368,26 @@ export const playlistQueries = {
.from(dbSchema.playlistClimbs)
.innerJoin(
tables.climbs,
eq(tables.climbs.uuid, dbSchema.playlistClimbs.climbUuid)
and(
eq(tables.climbs.uuid, dbSchema.playlistClimbs.climbUuid),
eq(tables.climbs.boardType, boardName)
)
)
.leftJoin(
tables.climbStats,
and(
eq(tables.climbStats.climbUuid, dbSchema.playlistClimbs.climbUuid),
eq(tables.climbStats.boardType, boardName),
// Use the route angle (from input) to fetch stats for the current board angle
eq(tables.climbStats.angle, input.angle)
)
)
.leftJoin(
tables.difficultyGrades,
eq(tables.difficultyGrades.difficulty, sql`ROUND(${tables.climbStats.displayDifficulty}::numeric)`)
and(
eq(tables.difficultyGrades.difficulty, sql`ROUND(${tables.climbStats.displayDifficulty}::numeric)`),
eq(tables.difficultyGrades.boardType, boardName)
)
)
.where(eq(dbSchema.playlistClimbs.playlistId, playlistId))
.orderBy(asc(dbSchema.playlistClimbs.position), asc(dbSchema.playlistClimbs.addedAt))
Expand Down
15 changes: 8 additions & 7 deletions packages/backend/src/graphql/resolvers/ticks/queries.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { eq, and, desc, inArray, sql, count } from 'drizzle-orm';
import type { ConnectionContext } from '@boardsesh/shared-schema';
import type { ConnectionContext, BoardName } from '@boardsesh/shared-schema';
import { SUPPORTED_BOARDS } from '@boardsesh/shared-schema';
import { db } from '../../../db/client';
import * as dbSchema from '@boardsesh/db/schema';
import { requireAuthenticated, validateInput } from '../shared/helpers';
Expand Down Expand Up @@ -315,13 +316,13 @@ export const tickQueries = {
boardType: tick.boardType,
layoutId,
angle: tick.angle,
isMirror: tick.isMirror,
isMirror: tick.isMirror ?? false,
status: tick.status,
attemptCount: tick.attemptCount,
quality: tick.quality,
difficulty: tick.difficulty,
difficultyName,
isBenchmark: tick.isBenchmark,
isBenchmark: tick.isBenchmark ?? false,
comment: tick.comment || '',
climbedAt: tick.climbedAt,
frames,
Expand All @@ -336,10 +337,10 @@ export const tickQueries = {
boardType: tick.boardType,
layoutId,
angle: tick.angle,
isMirror: tick.isMirror,
isMirror: tick.isMirror ?? false,
frames,
difficultyName,
isBenchmark: tick.isBenchmark,
isBenchmark: tick.isBenchmark ?? false,
date,
items: [],
flashCount: 0,
Expand Down Expand Up @@ -423,7 +424,7 @@ export const tickQueries = {
return { totalDistinctClimbs: 0, layoutStats: [] };
}

const boardTypes = ['kilter', 'tension'] as const;
const boardTypes = SUPPORTED_BOARDS;
const layoutStatsMap: Record<string, {
boardType: string;
layoutId: number | null;
Expand All @@ -432,7 +433,7 @@ export const tickQueries = {
const allClimbUuids = new Set<string>();

// Helper function to fetch stats for a single board type
const fetchBoardStats = async (boardType: 'kilter' | 'tension') => {
const fetchBoardStats = async (boardType: BoardName) => {
// Run both queries in parallel for this board type
const [gradeResults, distinctClimbs] = await Promise.all([
// Get distinct climb counts grouped by layoutId and difficulty using SQL aggregation
Expand Down
Loading
Loading