+ Please log in to save climbs to the database.{' '}
+
+ } style={{ padding: 0 }}>
+ Log in
+
+
+ >
+ }
+ type="warning"
+ showIcon
+ className={styles.warningAlert}
+ banner
+ />
+ )}
+
{/* Upload Section */}
{state.status === 'idle' && (
@@ -289,7 +348,7 @@ export default function MoonBoardBulkImport({
onClick={handleSaveAll}
size="large"
loading={isSaving}
- disabled={isSaving}
+ disabled={isSaving || !session?.user}
>
Save All ({state.climbs.length})
diff --git a/packages/web/app/components/queue-control/hooks/use-queue-data-fetching.tsx b/packages/web/app/components/queue-control/hooks/use-queue-data-fetching.tsx
index 44271ddd..bb7c9fd8 100644
--- a/packages/web/app/components/queue-control/hooks/use-queue-data-fetching.tsx
+++ b/packages/web/app/components/queue-control/hooks/use-queue-data-fetching.tsx
@@ -54,6 +54,9 @@ export const useQueueDataFetching = ({
] as const;
}, [searchParams, parsedParams]);
+ // Moonboard doesn't support GraphQL search yet
+ const isMoonboard = parsedParams.board_name === 'moonboard';
+
const {
data,
fetchNextPage,
@@ -64,6 +67,10 @@ export const useQueueDataFetching = ({
} = useInfiniteQuery({
queryKey,
queryFn: async ({ pageParam }): Promise => {
+ // Return empty results for Moonboard (GraphQL doesn't support it yet)
+ if (isMoonboard) {
+ return { climbs: [], totalCount: 0, hasMore: false };
+ }
// Build GraphQL input from search params
const input = {
boardName: parsedParams.board_name,
diff --git a/packages/web/app/components/setup-wizard/consolidated-board-config.tsx b/packages/web/app/components/setup-wizard/consolidated-board-config.tsx
index a4f893d7..fa74c344 100644
--- a/packages/web/app/components/setup-wizard/consolidated-board-config.tsx
+++ b/packages/web/app/components/setup-wizard/consolidated-board-config.tsx
@@ -11,9 +11,10 @@ import { useSession } from 'next-auth/react';
import { SUPPORTED_BOARDS, ANGLES } from '@/app/lib/board-data';
import { BoardName, BoardDetails } from '@/app/lib/types';
import { getDefaultSizeForLayout, getBoardDetails } from '@/app/lib/__generated__/product-sizes-data';
+import { getMoonBoardDetails } from '@/app/lib/moonboard-config';
import BoardConfigPreview from './board-config-preview';
import BoardRenderer from '../board-renderer/board-renderer';
-import { constructClimbListWithSlugs, constructCreateClimbWithSlugs } from '@/app/lib/url-utils';
+import { constructClimbListWithSlugs } from '@/app/lib/url-utils';
import { BoardConfigData } from '@/app/lib/server-board-configs';
import Logo from '../brand/logo';
import { themeTokens } from '@/app/theme/theme-config';
@@ -97,6 +98,13 @@ const ConsolidatedBoardConfig = ({ boardConfigs }: ConsolidatedBoardConfigProps)
if (cachedDetails) {
setPreviewBoardDetails(cachedDetails);
+ } else if (selectedBoard === 'moonboard') {
+ // Moonboard uses its own details function
+ const details = getMoonBoardDetails({
+ layout_id: selectedLayout,
+ set_ids: selectedSets,
+ });
+ setPreviewBoardDetails(details);
} else {
const details = getBoardDetails({
board_name: selectedBoard,
@@ -306,20 +314,7 @@ const ConsolidatedBoardConfig = ({ boardConfigs }: ConsolidatedBoardConfigProps)
.map((s) => s.name);
if (layout && size && selectedSetNames.length > 0) {
- // For MoonBoard, go to create climb page instead of list
- // (database/list view not implemented yet)
- if (selectedBoard === 'moonboard') {
- return constructCreateClimbWithSlugs(
- selectedBoard,
- layout.name,
- size.name,
- size.description,
- selectedSetNames,
- selectedAngle,
- );
- }
-
- // For Aurora boards (kilter, tension), go to list view
+ // Go to list view for all boards
return constructClimbListWithSlugs(
selectedBoard,
layout.name,
diff --git a/packages/web/app/lib/api-wrappers/aurora/saveClimb.ts b/packages/web/app/lib/api-wrappers/aurora/saveClimb.ts
index 72ec867c..0e2f3c10 100644
--- a/packages/web/app/lib/api-wrappers/aurora/saveClimb.ts
+++ b/packages/web/app/lib/api-wrappers/aurora/saveClimb.ts
@@ -3,6 +3,9 @@ import { SaveClimbOptions } from './types';
import { generateUuid } from './util';
import { dbz } from '@/app/lib/db/db';
import { UNIFIED_TABLES } from '@/app/lib/db/queries/util/table-select';
+import { generateHoldsHash } from '@/app/lib/climb-utils/holds-hash';
+import { convertLitUpHoldsStringToMap } from '@/app/components/board-renderer/util';
+import { and, eq } from 'drizzle-orm';
import dayjs from 'dayjs';
/**
@@ -14,43 +17,61 @@ import dayjs from 'dayjs';
export interface SaveClimbResponse {
uuid: string;
synced: boolean;
+ isDuplicate?: boolean;
+ existingClimbUuid?: string;
+ existingClimbName?: string;
}
export async function saveClimb(
board: BoardName,
options: SaveClimbOptions
): Promise {
+ const { climbs, climbHolds } = UNIFIED_TABLES;
+
+ // Generate holds hash for duplicate detection
+ const holdsHash = generateHoldsHash(options.frames);
+
+ // Check for existing climb with same holds (fast path for common case)
+ if (holdsHash) {
+ const existingClimb = await dbz
+ .select({
+ uuid: climbs.uuid,
+ name: climbs.name,
+ })
+ .from(climbs)
+ .where(
+ and(
+ eq(climbs.boardType, board),
+ eq(climbs.layoutId, options.layout_id),
+ eq(climbs.holdsHash, holdsHash)
+ )
+ )
+ .limit(1);
+
+ if (existingClimb.length > 0) {
+ return {
+ uuid: existingClimb[0].uuid,
+ synced: false,
+ isDuplicate: true,
+ existingClimbUuid: existingClimb[0].uuid,
+ existingClimbName: existingClimb[0].name || undefined,
+ };
+ }
+ }
+
+ // No duplicate found, create new climb
const uuid = generateUuid();
const createdAt = dayjs().format('YYYY-MM-DD HH:mm:ss');
- const { climbs } = UNIFIED_TABLES;
-
- await dbz
- .insert(climbs)
- .values({
- boardType: board,
- uuid,
- layoutId: options.layout_id,
- userId: options.user_id, // NextAuth user ID
- setterId: null, // No Aurora user ID
- name: options.name,
- description: options.description || '',
- angle: options.angle,
- framesCount: options.frames_count || 1,
- framesPace: options.frames_pace || 0,
- frames: options.frames,
- isDraft: options.is_draft,
- isListed: false,
- createdAt,
- synced: false,
- syncError: null,
- })
- .onConflictDoUpdate({
- target: climbs.uuid,
- set: {
+ try {
+ await dbz
+ .insert(climbs)
+ .values({
+ boardType: board,
+ uuid,
layoutId: options.layout_id,
- userId: options.user_id,
- setterId: null,
+ userId: options.user_id, // NextAuth user ID
+ setterId: null, // No Aurora user ID
name: options.name,
description: options.description || '',
angle: options.angle,
@@ -58,10 +79,81 @@ export async function saveClimb(
framesPace: options.frames_pace || 0,
frames: options.frames,
isDraft: options.is_draft,
+ isListed: false,
+ createdAt,
synced: false,
syncError: null,
- },
- });
+ holdsHash,
+ })
+ .onConflictDoUpdate({
+ target: climbs.uuid,
+ set: {
+ layoutId: options.layout_id,
+ userId: options.user_id,
+ setterId: null,
+ name: options.name,
+ description: options.description || '',
+ angle: options.angle,
+ framesCount: options.frames_count || 1,
+ framesPace: options.frames_pace || 0,
+ frames: options.frames,
+ isDraft: options.is_draft,
+ synced: false,
+ syncError: null,
+ holdsHash,
+ },
+ });
+ } catch (error) {
+ // Handle race condition: another request inserted the same holds between our check and insert
+ // The unique partial index on (board_type, layout_id, holds_hash) will raise a constraint violation
+ if (error instanceof Error && error.message.includes('board_climbs_holds_hash_unique_idx')) {
+ // Query for the existing climb that was inserted by the concurrent request
+ if (holdsHash) {
+ const existingClimb = await dbz
+ .select({
+ uuid: climbs.uuid,
+ name: climbs.name,
+ })
+ .from(climbs)
+ .where(
+ and(
+ eq(climbs.boardType, board),
+ eq(climbs.layoutId, options.layout_id),
+ eq(climbs.holdsHash, holdsHash)
+ )
+ )
+ .limit(1);
+
+ if (existingClimb.length > 0) {
+ return {
+ uuid: existingClimb[0].uuid,
+ synced: false,
+ isDuplicate: true,
+ existingClimbUuid: existingClimb[0].uuid,
+ existingClimbName: existingClimb[0].name || undefined,
+ };
+ }
+ }
+ }
+ // Re-throw other errors
+ throw error;
+ }
+
+ // Insert holds into board_climb_holds table
+ const holdsByFrame = convertLitUpHoldsStringToMap(options.frames, board);
+ const holdsToInsert = Object.entries(holdsByFrame).flatMap(([frameNumber, holds]) =>
+ Object.entries(holds).map(([holdId, { state }]) => ({
+ boardType: board,
+ climbUuid: uuid,
+ frameNumber: Number(frameNumber),
+ holdId: Number(holdId),
+ holdState: state,
+ }))
+ );
+
+ if (holdsToInsert.length > 0) {
+ await dbz.insert(climbHolds).values(holdsToInsert).onConflictDoNothing();
+ }
// Return response - always success from client perspective
return {
diff --git a/packages/web/app/lib/climb-utils/__tests__/holds-hash.test.ts b/packages/web/app/lib/climb-utils/__tests__/holds-hash.test.ts
new file mode 100644
index 00000000..0beb9f6b
--- /dev/null
+++ b/packages/web/app/lib/climb-utils/__tests__/holds-hash.test.ts
@@ -0,0 +1,108 @@
+import { describe, it, expect } from 'vitest';
+import { generateHoldsHash, framesAreEquivalent } from '../holds-hash';
+
+describe('generateHoldsHash', () => {
+ it('should return empty string for empty frames', () => {
+ expect(generateHoldsHash('')).toBe('');
+ expect(generateHoldsHash(' ')).toBe('');
+ });
+
+ it('should return empty string for frames with no valid holds', () => {
+ expect(generateHoldsHash('invalid')).toBe('');
+ expect(generateHoldsHash('abc123')).toBe('');
+ });
+
+ it('should generate hash for single hold', () => {
+ const hash = generateHoldsHash('p1r42');
+ expect(hash).toBe('1:42');
+ });
+
+ it('should generate hash for multiple holds', () => {
+ const hash = generateHoldsHash('p1r42p45r43p198r44');
+ expect(hash).toBe('1:42|45:43|198:44');
+ });
+
+ it('should produce same hash regardless of hold order', () => {
+ const hash1 = generateHoldsHash('p1r42p45r43p198r44');
+ const hash2 = generateHoldsHash('p198r44p1r42p45r43');
+ const hash3 = generateHoldsHash('p45r43p198r44p1r42');
+
+ expect(hash1).toBe(hash2);
+ expect(hash2).toBe(hash3);
+ });
+
+ it('should handle multi-frame climbs (comma-separated)', () => {
+ const hash = generateHoldsHash('p1r42p2r43,p3r44p4r42');
+ // All holds from all frames should be included and sorted
+ expect(hash).toBe('1:42|2:43|3:44|4:42');
+ });
+
+ it('should produce same hash for equivalent multi-frame climbs with different frame organization', () => {
+ // Same holds, organized differently across frames
+ const hash1 = generateHoldsHash('p1r42p2r43,p3r44');
+ const hash2 = generateHoldsHash('p1r42,p2r43p3r44');
+ const hash3 = generateHoldsHash('p3r44p2r43p1r42');
+
+ expect(hash1).toBe(hash2);
+ expect(hash2).toBe(hash3);
+ });
+
+ it('should differentiate climbs with same holds but different states', () => {
+ // Same hold IDs but different role codes (states)
+ const hash1 = generateHoldsHash('p1r42p2r43'); // hold 1 is start (42), hold 2 is hand (43)
+ const hash2 = generateHoldsHash('p1r43p2r42'); // hold 1 is hand (43), hold 2 is start (42)
+
+ expect(hash1).not.toBe(hash2);
+ });
+
+ it('should handle holds with same ID but different states (sorted by roleCode)', () => {
+ // Edge case: same holdId appearing with different states
+ const hash = generateHoldsHash('p1r44p1r42');
+ // Should be sorted by holdId first, then roleCode
+ expect(hash).toBe('1:42|1:44');
+ });
+
+ it('should handle Kilter/Tension style frames', () => {
+ // Kilter uses codes like 42, 43, 44, 45 for STARTING, HAND, FINISH, FOOT
+ const hash = generateHoldsHash('p100r42p200r43p300r44p400r45');
+ expect(hash).toBe('100:42|200:43|300:44|400:45');
+ });
+
+ it('should handle MoonBoard style frames', () => {
+ // MoonBoard also uses 42, 43, 44 for start, hand, finish
+ const hash = generateHoldsHash('p1r42p45r43p198r44');
+ expect(hash).toBe('1:42|45:43|198:44');
+ });
+
+ it('should ignore empty frames in comma-separated string', () => {
+ const hash1 = generateHoldsHash('p1r42,,p2r43');
+ const hash2 = generateHoldsHash('p1r42,p2r43');
+ expect(hash1).toBe(hash2);
+ });
+});
+
+describe('framesAreEquivalent', () => {
+ it('should return true for identical frames', () => {
+ expect(framesAreEquivalent('p1r42p2r43', 'p1r42p2r43')).toBe(true);
+ });
+
+ it('should return true for frames with same holds in different order', () => {
+ expect(framesAreEquivalent('p1r42p2r43', 'p2r43p1r42')).toBe(true);
+ });
+
+ it('should return true for equivalent multi-frame climbs', () => {
+ expect(framesAreEquivalent('p1r42,p2r43', 'p2r43p1r42')).toBe(true);
+ });
+
+ it('should return false for frames with different holds', () => {
+ expect(framesAreEquivalent('p1r42p2r43', 'p1r42p3r43')).toBe(false);
+ });
+
+ it('should return false for frames with same holds but different states', () => {
+ expect(framesAreEquivalent('p1r42p2r43', 'p1r43p2r42')).toBe(false);
+ });
+
+ it('should return true for both empty frames', () => {
+ expect(framesAreEquivalent('', '')).toBe(true);
+ });
+});
diff --git a/packages/web/app/lib/climb-utils/holds-hash.ts b/packages/web/app/lib/climb-utils/holds-hash.ts
new file mode 100644
index 00000000..0add5b67
--- /dev/null
+++ b/packages/web/app/lib/climb-utils/holds-hash.ts
@@ -0,0 +1,6 @@
+/**
+ * Re-export holds hash utilities from @boardsesh/db package.
+ * The implementation lives in packages/db so it can be shared with
+ * the backfill script without code duplication.
+ */
+export { generateHoldsHash, framesAreEquivalent } from '@boardsesh/db';
diff --git a/packages/web/app/lib/data-sync/aurora/shared-sync.ts b/packages/web/app/lib/data-sync/aurora/shared-sync.ts
index 1601c249..edb4551d 100644
--- a/packages/web/app/lib/data-sync/aurora/shared-sync.ts
+++ b/packages/web/app/lib/data-sync/aurora/shared-sync.ts
@@ -7,6 +7,7 @@ import { NeonDatabase } from 'drizzle-orm/neon-serverless';
import { Attempt, BetaLink, Climb, ClimbStats, SharedSync, SyncPutFields } from '../../api-wrappers/sync-api-types';
import { UNIFIED_TABLES } from '../../db/queries/util/table-select';
import { convertLitUpHoldsStringToMap } from '@/app/components/board-renderer/util';
+import { generateHoldsHash } from '@/app/lib/climb-utils/holds-hash';
// Define shared sync tables in correct dependency order
// Order matches what the Android app sends - keep full list to remain indistinguishable
@@ -145,6 +146,9 @@ async function upsertClimbs(db: NeonDatabase>, board: Auro
await Promise.all(
data.map(async (item: Climb) => {
+ // Compute holds hash for duplicate detection
+ const holdsHash = generateHoldsHash(item.frames);
+
// Insert or update the climb
await db
.insert(climbsSchema)
@@ -168,6 +172,7 @@ async function upsertClimbs(db: NeonDatabase>, board: Auro
isListed: item.is_listed,
createdAt: item.created_at,
angle: item.angle,
+ holdsHash,
})
.onConflictDoUpdate({
target: [climbsSchema.uuid],
@@ -192,6 +197,8 @@ async function upsertClimbs(db: NeonDatabase>, board: Auro
setterUsername: climbsSchema.setterUsername,
layoutId: climbsSchema.layoutId,
angle: climbsSchema.angle,
+ // Update holdsHash if not already set (backfill during sync)
+ holdsHash: sql`COALESCE(${climbsSchema.holdsHash}, ${holdsHash})`,
},
});
diff --git a/packages/web/app/lib/moonboard-climbs-db.ts b/packages/web/app/lib/moonboard-climbs-db.ts
index c6b0eb44..28a22aab 100644
--- a/packages/web/app/lib/moonboard-climbs-db.ts
+++ b/packages/web/app/lib/moonboard-climbs-db.ts
@@ -1,114 +1,6 @@
-import { openDB, IDBPDatabase } from 'idb';
import { coordinateToHoldId, MOONBOARD_HOLD_STATES, type MoonBoardCoordinate } from './moonboard-config';
import type { MoonBoardLitUpHoldsMap } from '../components/moonboard-renderer/types';
-const DB_NAME = 'boardsesh-moonboard';
-const DB_VERSION = 1;
-const STORE_NAME = 'climbs';
-
-export interface MoonBoardStoredClimb {
- id: string;
- name: string;
- description: string;
- holds: {
- start: string[];
- hand: string[];
- finish: string[];
- };
- angle: number;
- layoutFolder: string;
- createdAt: string;
- importedFrom?: string;
-}
-
-let dbPromise: Promise | null = null;
-
-const initDB = async (): Promise => {
- if (!dbPromise) {
- dbPromise = openDB(DB_NAME, DB_VERSION, {
- upgrade(db) {
- if (!db.objectStoreNames.contains(STORE_NAME)) {
- const store = db.createObjectStore(STORE_NAME, { keyPath: 'id' });
- store.createIndex('layoutFolder', 'layoutFolder', { unique: false });
- store.createIndex('createdAt', 'createdAt', { unique: false });
- }
- },
- });
- }
- return dbPromise;
-};
-
-/**
- * Get all moonboard climbs from IndexedDB
- */
-export const getMoonBoardClimbs = async (): Promise => {
- try {
- const db = await initDB();
- return await db.getAll(STORE_NAME);
- } catch (error) {
- console.error('Failed to get moonboard climbs:', error);
- return [];
- }
-};
-
-/**
- * Get moonboard climbs by layout folder
- */
-export const getMoonBoardClimbsByLayout = async (layoutFolder: string): Promise => {
- try {
- const db = await initDB();
- return await db.getAllFromIndex(STORE_NAME, 'layoutFolder', layoutFolder);
- } catch (error) {
- console.error('Failed to get moonboard climbs by layout:', error);
- return [];
- }
-};
-
-/**
- * Save a moonboard climb to IndexedDB
- */
-export const saveMoonBoardClimb = async (climb: Omit): Promise => {
- const db = await initDB();
- const climbWithId: MoonBoardStoredClimb = {
- ...climb,
- id: crypto.randomUUID(),
- };
- await db.put(STORE_NAME, climbWithId);
- return climbWithId;
-};
-
-/**
- * Save multiple moonboard climbs to IndexedDB
- */
-export const saveMoonBoardClimbs = async (
- climbs: Array>,
-): Promise => {
- const db = await initDB();
- const tx = db.transaction(STORE_NAME, 'readwrite');
-
- const savedClimbs: MoonBoardStoredClimb[] = climbs.map((climb) => ({
- ...climb,
- id: crypto.randomUUID(),
- }));
-
- await Promise.all([...savedClimbs.map((climb) => tx.store.put(climb)), tx.done]);
-
- return savedClimbs;
-};
-
-/**
- * Delete a moonboard climb from IndexedDB
- */
-export const deleteMoonBoardClimb = async (id: string): Promise => {
- try {
- const db = await initDB();
- await db.delete(STORE_NAME, id);
- } catch (error) {
- console.error('Failed to delete moonboard climb:', error);
- throw error;
- }
-};
-
/**
* Convert OCR hold coordinates to the lit up holds map format for the renderer
* This is a shared utility used by both the create form and bulk import
diff --git a/packages/web/app/lib/moonboard-config.ts b/packages/web/app/lib/moonboard-config.ts
index 6ec62673..d13a006a 100644
--- a/packages/web/app/lib/moonboard-config.ts
+++ b/packages/web/app/lib/moonboard-config.ts
@@ -1,9 +1,8 @@
// MoonBoard Configuration
// This file contains all MoonBoard-specific configuration that differs from Aurora boards
-// Feature flag - auto-enabled in development, or via MOONBOARD_ENABLED env var
-export const MOONBOARD_ENABLED =
- process.env.NODE_ENV === 'development' || process.env.MOONBOARD_ENABLED === 'true';
+// Feature flag - enabled by default
+export const MOONBOARD_ENABLED = true;
// MoonBoard layout types (equivalent to Aurora "layouts")
export const MOONBOARD_LAYOUTS = {
@@ -82,6 +81,13 @@ export const MOONBOARD_HOLD_STATES = {
finish: { name: 'FINISH' as const, color: '#00FF00', displayColor: '#44FF44' }, // Green
} as const;
+// Hold state codes for frames encoding (compatible with Aurora format)
+export const MOONBOARD_HOLD_STATE_CODES = {
+ start: 42, // Using 42 for STARTING (matches Kilter pattern)
+ hand: 43, // Using 43 for HAND
+ finish: 44, // Using 44 for FINISH
+} as const;
+
// Grid coordinate types
export type MoonBoardColumn = (typeof MOONBOARD_GRID.columns)[number];
export type MoonBoardRow = (typeof MOONBOARD_GRID.rows)[number];
@@ -213,3 +219,32 @@ export function getMoonBoardDetails({
holdsData: [],
};
}
+
+/**
+ * Encode MoonBoard holds to frames format for database storage.
+ * Format: p{holdId}r{roleCode} (e.g., "p1r42p45r43p198r44")
+ */
+export function encodeMoonBoardHoldsToFrames(holds: {
+ start: string[];
+ hand: string[];
+ finish: string[];
+}): string {
+ const parts: string[] = [];
+
+ holds.start.forEach((coord) => {
+ const holdId = coordinateToHoldId(coord as MoonBoardCoordinate);
+ parts.push(`p${holdId}r${MOONBOARD_HOLD_STATE_CODES.start}`);
+ });
+
+ holds.hand.forEach((coord) => {
+ const holdId = coordinateToHoldId(coord as MoonBoardCoordinate);
+ parts.push(`p${holdId}r${MOONBOARD_HOLD_STATE_CODES.hand}`);
+ });
+
+ holds.finish.forEach((coord) => {
+ const holdId = coordinateToHoldId(coord as MoonBoardCoordinate);
+ parts.push(`p${holdId}r${MOONBOARD_HOLD_STATE_CODES.finish}`);
+ });
+
+ return parts.join('');
+}