From 43651664a82247e983f5dae6b01d6e3c2a078bb2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Nov 2025 10:51:19 +0000 Subject: [PATCH 1/5] Fix auxiliary holds not appearing in URL for homewall setups The getSetsBySlug function had overlapping matching conditions where "Auxiliary Kickboard" could potentially match both 'aux-kicker' AND 'aux' slugs, and similarly "Mainline Kickboard" could match both 'main-kicker' AND 'main' slugs. Added explicit exclusion of 'kickboard' in the 'aux' and 'main' checks to ensure mutually exclusive matching: - 'aux-kicker' only matches sets with both 'auxiliary' AND 'kickboard' - 'aux' only matches sets with 'auxiliary' but NOT 'kickboard' - 'main-kicker' only matches sets with both 'mainline' AND 'kickboard' - 'main' only matches sets with 'mainline' but NOT 'kickboard' This fixes the issue where selecting a fullride configuration on Kilter homewall would result in auxiliary holds not being properly resolved from the URL slug. --- app/lib/slug-utils.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/lib/slug-utils.ts b/app/lib/slug-utils.ts index 6c22eda..1e88a81 100644 --- a/app/lib/slug-utils.ts +++ b/app/lib/slug-utils.ts @@ -133,10 +133,14 @@ export const getSetsBySlug = async ( ) { return true; } - if (lowercaseName.includes('auxiliary') && slugParts.includes('aux')) { + // For 'aux' slug, match sets that have 'auxiliary' but NOT 'kickboard' + // This ensures "Auxiliary Kickboard" only matches 'aux-kicker', not 'aux' + if (lowercaseName.includes('auxiliary') && !lowercaseName.includes('kickboard') && slugParts.includes('aux')) { return true; } - if (lowercaseName.includes('mainline') && slugParts.includes('main')) { + // For 'main' slug, match sets that have 'mainline' but NOT 'kickboard' + // This ensures "Mainline Kickboard" only matches 'main-kicker', not 'main' + if (lowercaseName.includes('mainline') && !lowercaseName.includes('kickboard') && slugParts.includes('main')) { return true; } From 38b0df795670e20aed7e180e265d0efc3daa9a2a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Nov 2025 10:52:03 +0000 Subject: [PATCH 2/5] Update package-lock.json --- package-lock.json | 168 ++++++++++++++++++++++++++++++++-------------- 1 file changed, 119 insertions(+), 49 deletions(-) diff --git a/package-lock.json b/package-lock.json index 87f6d10..f7048ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -82,7 +82,6 @@ }, "node_modules/@ampproject/remapping": { "version": "2.3.0", - "dev": true, "license": "Apache-2.0", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -459,6 +458,71 @@ } }, "node_modules/@auth/core": { + "version": "0.34.2", + "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.34.2.tgz", + "integrity": "sha512-KywHKRgLiF3l7PLyL73fjLSIBe1YNcA6sMeew4yMP6cfCWGXZrkkXd32AjRi1hlJ9nvovUBGZHvbn+LijO6ZeQ==", + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "@panva/hkdf": "^1.1.1", + "@types/cookie": "0.6.0", + "cookie": "0.6.0", + "jose": "^5.1.3", + "oauth4webapi": "^2.10.4", + "preact": "10.11.3", + "preact-render-to-string": "5.2.3" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "nodemailer": "^6.8.0" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, + "node_modules/@auth/core/node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@auth/core/node_modules/preact": { + "version": "10.11.3", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.11.3.tgz", + "integrity": "sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==", + "license": "MIT", + "optional": true, + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/@auth/drizzle-adapter": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@auth/drizzle-adapter/-/drizzle-adapter-1.10.0.tgz", + "integrity": "sha512-3MKsdAINTfvV4QKev8PFMNG93HJEUHh9sggDXnmUmriFogRf8qLvgqnPsTlfUyWcLwTzzrrYjeu8CGM+4IxHwQ==", + "license": "ISC", + "dependencies": { + "@auth/core": "0.40.0" + } + }, + "node_modules/@auth/drizzle-adapter/node_modules/@auth/core": { "version": "0.40.0", "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.40.0.tgz", "integrity": "sha512-n53uJE0RH5SqZ7N1xZoMKekbHfQgjd0sAEyUbE+IYJnmuQkbvuZnXItCU7d+i7Fj8VGOgqvNO7Mw4YfBTlZeQw==", @@ -487,13 +551,31 @@ } } }, - "node_modules/@auth/drizzle-adapter": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@auth/drizzle-adapter/-/drizzle-adapter-1.10.0.tgz", - "integrity": "sha512-3MKsdAINTfvV4QKev8PFMNG93HJEUHh9sggDXnmUmriFogRf8qLvgqnPsTlfUyWcLwTzzrrYjeu8CGM+4IxHwQ==", - "license": "ISC", - "dependencies": { - "@auth/core": "0.40.0" + "node_modules/@auth/drizzle-adapter/node_modules/jose": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.2.tgz", + "integrity": "sha512-MpcPtHLE5EmztuFIqB0vzHAWJPpmN1E6L4oo+kze56LIs3MyXIj9ZHMDxqOvkP38gBR7K1v3jqd4WU2+nrfONQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/@auth/drizzle-adapter/node_modules/oauth4webapi": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.3.tgz", + "integrity": "sha512-pQ5BsX3QRTgnt5HxgHwgunIRaDXBdkT23tf8dfzmtTIL2LTpdmxgbpbBm0VgFWAIDlezQvQCTgnVIUmHupXHxw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/@auth/drizzle-adapter/node_modules/preact-render-to-string": { + "version": "6.5.11", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz", + "integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==", + "license": "MIT", + "peerDependencies": { + "preact": ">=10" } }, "node_modules/@babel/code-frame": { @@ -510,7 +592,6 @@ }, "node_modules/@babel/compat-data": { "version": "7.28.0", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -518,7 +599,6 @@ }, "node_modules/@babel/core": { "version": "7.28.0", - "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.2.0", @@ -547,12 +627,10 @@ }, "node_modules/@babel/core/node_modules/convert-source-map": { "version": "2.0.0", - "dev": true, "license": "MIT" }, "node_modules/@babel/core/node_modules/json5": { "version": "2.2.3", - "dev": true, "license": "MIT", "bin": { "json5": "lib/cli.js" @@ -563,7 +641,6 @@ }, "node_modules/@babel/core/node_modules/semver": { "version": "6.3.1", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -585,7 +662,6 @@ }, "node_modules/@babel/helper-compilation-targets": { "version": "7.27.2", - "dev": true, "license": "MIT", "dependencies": { "@babel/compat-data": "^7.27.2", @@ -600,7 +676,6 @@ }, "node_modules/@babel/helper-compilation-targets/node_modules/semver": { "version": "6.3.1", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -626,7 +701,6 @@ }, "node_modules/@babel/helper-module-transforms": { "version": "7.27.3", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.27.1", @@ -664,7 +738,6 @@ }, "node_modules/@babel/helper-validator-option": { "version": "7.27.1", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -672,7 +745,6 @@ }, "node_modules/@babel/helpers": { "version": "7.27.6", - "dev": true, "license": "MIT", "dependencies": { "@babel/template": "^7.27.2", @@ -2190,7 +2262,7 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/d3-scale": { @@ -2247,12 +2319,12 @@ }, "node_modules/@types/prop-types": { "version": "15.7.15", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.23", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -3201,7 +3273,6 @@ }, "node_modules/browserslist": { "version": "4.25.1", - "dev": true, "funding": [ { "type": "opencollective", @@ -3236,7 +3307,7 @@ }, "node_modules/bufferutil": { "version": "4.0.9", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -3941,7 +4012,6 @@ }, "node_modules/electron-to-chromium": { "version": "1.5.183", - "dev": true, "license": "ISC" }, "node_modules/emoji-regex": { @@ -4191,7 +4261,6 @@ }, "node_modules/escalade": { "version": "3.2.0", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -4913,7 +4982,6 @@ }, "node_modules/gensync": { "version": "1.0.0-beta.2", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -5786,10 +5854,12 @@ } }, "node_modules/jose": { - "version": "6.0.12", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.0.12.tgz", - "integrity": "sha512-T8xypXs8CpmiIi78k0E+Lk7T2zlK4zDyg+o1CZ4AkOHgDg98ogdP2BeZ61lTFKFyoEwJ9RgAgN+SdM3iPgNonQ==", + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", "license": "MIT", + "optional": true, + "peer": true, "funding": { "url": "https://github.com/sponsors/panva" } @@ -6004,7 +6074,6 @@ }, "node_modules/lru-cache": { "version": "5.1.1", - "dev": true, "license": "ISC", "dependencies": { "yallist": "^3.0.2" @@ -6272,12 +6341,6 @@ "preact": ">=10" } }, - "node_modules/next-auth/node_modules/pretty-format": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz", - "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==", - "license": "MIT" - }, "node_modules/next-auth/node_modules/uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", @@ -6289,7 +6352,7 @@ }, "node_modules/node-gyp-build": { "version": "4.8.4", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "node-gyp-build": "bin.js", @@ -6299,7 +6362,6 @@ }, "node_modules/node-releases": { "version": "2.0.19", - "dev": true, "license": "MIT" }, "node_modules/nwsapi": { @@ -6314,10 +6376,12 @@ "license": "MIT" }, "node_modules/oauth4webapi": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.6.2.tgz", - "integrity": "sha512-hwWLiyBYuqhVdcIUJMJVKdEvz+DCweOcbSfqDyIv9PuUwrNfqrzfHP2bypZgZdbYOS67QYqnAnvZa2BJwBBrHw==", + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-2.17.0.tgz", + "integrity": "sha512-lbC0Z7uzAFNFyzEYRIC+pkSVvDHJTbEW+dYlSBAlCYDe6RxUkJ26bClhk8ocBZip1wfI9uKTe0fm4Ib4RHn6uQ==", "license": "MIT", + "optional": true, + "peer": true, "funding": { "url": "https://github.com/sponsors/panva" } @@ -6939,10 +7003,15 @@ } }, "node_modules/preact-render-to-string": { - "version": "6.5.11", - "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz", - "integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==", + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.3.tgz", + "integrity": "sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==", "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "pretty-format": "^3.8.0" + }, "peerDependencies": { "preact": ">=10" } @@ -6980,6 +7049,12 @@ "node": ">=6.0.0" } }, + "node_modules/pretty-format": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz", + "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==", + "license": "MIT" + }, "node_modules/prop-types": { "version": "15.8.1", "license": "MIT", @@ -7564,7 +7639,6 @@ }, "node_modules/react": { "version": "18.3.1", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.1.0" @@ -7583,7 +7657,6 @@ }, "node_modules/react-dom": { "version": "18.3.1", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.1.0", @@ -7902,7 +7975,6 @@ }, "node_modules/scheduler": { "version": "0.23.2", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.1.0" @@ -8864,7 +8936,6 @@ }, "node_modules/update-browserslist-db": { "version": "1.1.3", - "dev": true, "funding": [ { "type": "opencollective", @@ -9425,7 +9496,6 @@ }, "node_modules/yallist": { "version": "3.1.1", - "dev": true, "license": "ISC" }, "node_modules/yocto-queue": { From e61c733e60d65c2cc03453f443d1a73c9cd7345d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Nov 2025 11:05:45 +0000 Subject: [PATCH 3/5] Improve homewall set name matching to support Aux/Main variants Updated both URL generation (generateSetSlug) and URL parsing (getSetsBySlug) to handle set names that use abbreviated forms like "Aux" or "Main" instead of the full "Auxiliary" or "Mainline". The matching now checks for: - 'auxiliary' OR 'aux' for aux-related sets - 'mainline' OR 'main' for main-related sets This ensures proper matching regardless of whether the database contains set names like "Auxiliary", "Aux", "Auxiliary Kickboard", or "Aux Kickboard". The key fix remains: sets with 'kickboard' in the name will only match the '-kicker' slug variants, while sets without 'kickboard' will only match the plain 'aux' or 'main' slugs. --- app/lib/slug-utils.ts | 30 +++++++++++++----------------- app/lib/url-utils.ts | 14 +++++++++----- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/app/lib/slug-utils.ts b/app/lib/slug-utils.ts index 1e88a81..d0f0706 100644 --- a/app/lib/slug-utils.ts +++ b/app/lib/slug-utils.ts @@ -118,29 +118,25 @@ export const getSetsBySlug = async ( const matchingSets = rows.filter((s) => { const lowercaseName = s.name.toLowerCase().trim(); - // Handle homewall-specific set names - if ( - lowercaseName.includes('auxiliary') && - lowercaseName.includes('kickboard') && - slugParts.includes('aux-kicker') - ) { + // Handle homewall-specific set names (supports both "Auxiliary/Mainline" and "Aux/Main" variants) + const hasAux = lowercaseName.includes('auxiliary') || lowercaseName.includes('aux'); + const hasMain = lowercaseName.includes('mainline') || lowercaseName.includes('main'); + const hasKickboard = lowercaseName.includes('kickboard'); + + // Match aux-kicker: sets with aux/auxiliary AND kickboard + if (hasAux && hasKickboard && slugParts.includes('aux-kicker')) { return true; } - if ( - lowercaseName.includes('mainline') && - lowercaseName.includes('kickboard') && - slugParts.includes('main-kicker') - ) { + // Match main-kicker: sets with main/mainline AND kickboard + if (hasMain && hasKickboard && slugParts.includes('main-kicker')) { return true; } - // For 'aux' slug, match sets that have 'auxiliary' but NOT 'kickboard' - // This ensures "Auxiliary Kickboard" only matches 'aux-kicker', not 'aux' - if (lowercaseName.includes('auxiliary') && !lowercaseName.includes('kickboard') && slugParts.includes('aux')) { + // Match aux: sets with aux/auxiliary but NOT kickboard + if (hasAux && !hasKickboard && slugParts.includes('aux')) { return true; } - // For 'main' slug, match sets that have 'mainline' but NOT 'kickboard' - // This ensures "Mainline Kickboard" only matches 'main-kicker', not 'main' - if (lowercaseName.includes('mainline') && !lowercaseName.includes('kickboard') && slugParts.includes('main')) { + // Match main: sets with main/mainline but NOT kickboard + if (hasMain && !hasKickboard && slugParts.includes('main')) { return true; } diff --git a/app/lib/url-utils.ts b/app/lib/url-utils.ts index 532790c..f84075b 100644 --- a/app/lib/url-utils.ts +++ b/app/lib/url-utils.ts @@ -334,17 +334,21 @@ export const generateSetSlug = (setNames: string[]): string => { .map((name) => { const lowercaseName = name.toLowerCase().trim(); - // Handle homewall-specific set names - if (lowercaseName.includes('auxiliary') && lowercaseName.includes('kickboard')) { + // Handle homewall-specific set names (supports both "Auxiliary/Mainline" and "Aux/Main" variants) + const hasAux = lowercaseName.includes('auxiliary') || lowercaseName.includes('aux'); + const hasMain = lowercaseName.includes('mainline') || lowercaseName.includes('main'); + const hasKickboard = lowercaseName.includes('kickboard'); + + if (hasAux && hasKickboard) { return 'aux-kicker'; } - if (lowercaseName.includes('mainline') && lowercaseName.includes('kickboard')) { + if (hasMain && hasKickboard) { return 'main-kicker'; } - if (lowercaseName.includes('auxiliary')) { + if (hasAux) { return 'aux'; } - if (lowercaseName.includes('mainline')) { + if (hasMain) { return 'main'; } From 3a26515d5b2a3c17d562fd2d295683814b9fd52d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Nov 2025 11:11:00 +0000 Subject: [PATCH 4/5] Add comprehensive test coverage for slug parsing and generation - Extract matchSetNameToSlugParts into separate testable module (slug-matching.ts) - Add 90+ new test cases for matchSetNameToSlugParts covering: - Full name variants (Auxiliary/Mainline) - Abbreviated variants (Aux/Main) - Kickboard vs non-kickboard mutual exclusivity - Full ride scenarios (all 4 sets) - Partial selection scenarios - Case insensitivity - Whitespace handling - Original kilter/tension sets (bolt/screw) - Real-world URL scenarios - Expand generateSetSlug tests to cover: - Abbreviated names (Aux/Main) - Mixed full and abbreviated names - All partial selection combinations - Consistent sorting behavior - Edge cases This comprehensive test suite ensures the critical bug fix for auxiliary holds rendering is properly validated and prevents regressions. --- app/lib/__tests__/slug-utils.test.ts | 458 +++++++++++++++++++++++++++ app/lib/__tests__/url-utils.test.ts | 203 +++++++++++- app/lib/slug-matching.ts | 40 +++ app/lib/slug-utils.ts | 50 +-- 4 files changed, 702 insertions(+), 49 deletions(-) create mode 100644 app/lib/__tests__/slug-utils.test.ts create mode 100644 app/lib/slug-matching.ts diff --git a/app/lib/__tests__/slug-utils.test.ts b/app/lib/__tests__/slug-utils.test.ts new file mode 100644 index 0000000..82da497 --- /dev/null +++ b/app/lib/__tests__/slug-utils.test.ts @@ -0,0 +1,458 @@ +import { describe, it, expect } from 'vitest'; +import { matchSetNameToSlugParts } from '../slug-matching'; + +describe('matchSetNameToSlugParts', () => { + describe('homewall sets - full names (Auxiliary/Mainline)', () => { + describe('Auxiliary Kickboard', () => { + it('should match aux-kicker slug', () => { + expect(matchSetNameToSlugParts('Auxiliary Kickboard', ['aux-kicker'])).toBe(true); + }); + + it('should NOT match aux slug (kickboard sets only match -kicker)', () => { + expect(matchSetNameToSlugParts('Auxiliary Kickboard', ['aux'])).toBe(false); + }); + + it('should NOT match main-kicker slug', () => { + expect(matchSetNameToSlugParts('Auxiliary Kickboard', ['main-kicker'])).toBe(false); + }); + + it('should NOT match main slug', () => { + expect(matchSetNameToSlugParts('Auxiliary Kickboard', ['main'])).toBe(false); + }); + }); + + describe('Mainline Kickboard', () => { + it('should match main-kicker slug', () => { + expect(matchSetNameToSlugParts('Mainline Kickboard', ['main-kicker'])).toBe(true); + }); + + it('should NOT match main slug (kickboard sets only match -kicker)', () => { + expect(matchSetNameToSlugParts('Mainline Kickboard', ['main'])).toBe(false); + }); + + it('should NOT match aux-kicker slug', () => { + expect(matchSetNameToSlugParts('Mainline Kickboard', ['aux-kicker'])).toBe(false); + }); + + it('should NOT match aux slug', () => { + expect(matchSetNameToSlugParts('Mainline Kickboard', ['aux'])).toBe(false); + }); + }); + + describe('Auxiliary (standalone)', () => { + it('should match aux slug', () => { + expect(matchSetNameToSlugParts('Auxiliary', ['aux'])).toBe(true); + }); + + it('should NOT match aux-kicker slug (non-kickboard sets only match plain slug)', () => { + expect(matchSetNameToSlugParts('Auxiliary', ['aux-kicker'])).toBe(false); + }); + + it('should NOT match main slug', () => { + expect(matchSetNameToSlugParts('Auxiliary', ['main'])).toBe(false); + }); + + it('should NOT match main-kicker slug', () => { + expect(matchSetNameToSlugParts('Auxiliary', ['main-kicker'])).toBe(false); + }); + }); + + describe('Mainline (standalone)', () => { + it('should match main slug', () => { + expect(matchSetNameToSlugParts('Mainline', ['main'])).toBe(true); + }); + + it('should NOT match main-kicker slug (non-kickboard sets only match plain slug)', () => { + expect(matchSetNameToSlugParts('Mainline', ['main-kicker'])).toBe(false); + }); + + it('should NOT match aux slug', () => { + expect(matchSetNameToSlugParts('Mainline', ['aux'])).toBe(false); + }); + + it('should NOT match aux-kicker slug', () => { + expect(matchSetNameToSlugParts('Mainline', ['aux-kicker'])).toBe(false); + }); + }); + }); + + describe('homewall sets - abbreviated names (Aux/Main)', () => { + describe('Aux Kickboard', () => { + it('should match aux-kicker slug', () => { + expect(matchSetNameToSlugParts('Aux Kickboard', ['aux-kicker'])).toBe(true); + }); + + it('should NOT match aux slug', () => { + expect(matchSetNameToSlugParts('Aux Kickboard', ['aux'])).toBe(false); + }); + }); + + describe('Main Kickboard', () => { + it('should match main-kicker slug', () => { + expect(matchSetNameToSlugParts('Main Kickboard', ['main-kicker'])).toBe(true); + }); + + it('should NOT match main slug', () => { + expect(matchSetNameToSlugParts('Main Kickboard', ['main'])).toBe(false); + }); + }); + + describe('Aux (standalone)', () => { + it('should match aux slug', () => { + expect(matchSetNameToSlugParts('Aux', ['aux'])).toBe(true); + }); + + it('should NOT match aux-kicker slug', () => { + expect(matchSetNameToSlugParts('Aux', ['aux-kicker'])).toBe(false); + }); + }); + + describe('Main (standalone)', () => { + it('should match main slug', () => { + expect(matchSetNameToSlugParts('Main', ['main'])).toBe(true); + }); + + it('should NOT match main-kicker slug', () => { + expect(matchSetNameToSlugParts('Main', ['main-kicker'])).toBe(false); + }); + }); + }); + + describe('homewall full ride - all four sets with full slug', () => { + const fullRideSlugParts = ['main-kicker', 'main', 'aux-kicker', 'aux']; + + it('should match Auxiliary Kickboard to aux-kicker', () => { + expect(matchSetNameToSlugParts('Auxiliary Kickboard', fullRideSlugParts)).toBe(true); + }); + + it('should match Mainline Kickboard to main-kicker', () => { + expect(matchSetNameToSlugParts('Mainline Kickboard', fullRideSlugParts)).toBe(true); + }); + + it('should match Auxiliary to aux', () => { + expect(matchSetNameToSlugParts('Auxiliary', fullRideSlugParts)).toBe(true); + }); + + it('should match Mainline to main', () => { + expect(matchSetNameToSlugParts('Mainline', fullRideSlugParts)).toBe(true); + }); + + it('should match Aux Kickboard to aux-kicker', () => { + expect(matchSetNameToSlugParts('Aux Kickboard', fullRideSlugParts)).toBe(true); + }); + + it('should match Main Kickboard to main-kicker', () => { + expect(matchSetNameToSlugParts('Main Kickboard', fullRideSlugParts)).toBe(true); + }); + + it('should match Aux to aux', () => { + expect(matchSetNameToSlugParts('Aux', fullRideSlugParts)).toBe(true); + }); + + it('should match Main to main', () => { + expect(matchSetNameToSlugParts('Main', fullRideSlugParts)).toBe(true); + }); + }); + + describe('homewall partial selections - critical bug fix scenarios', () => { + describe('selecting only aux (not aux-kicker)', () => { + const slugParts = ['aux']; + + it('should match Auxiliary', () => { + expect(matchSetNameToSlugParts('Auxiliary', slugParts)).toBe(true); + }); + + it('should match Aux', () => { + expect(matchSetNameToSlugParts('Aux', slugParts)).toBe(true); + }); + + it('should NOT match Auxiliary Kickboard', () => { + expect(matchSetNameToSlugParts('Auxiliary Kickboard', slugParts)).toBe(false); + }); + + it('should NOT match Aux Kickboard', () => { + expect(matchSetNameToSlugParts('Aux Kickboard', slugParts)).toBe(false); + }); + }); + + describe('selecting only main (not main-kicker)', () => { + const slugParts = ['main']; + + it('should match Mainline', () => { + expect(matchSetNameToSlugParts('Mainline', slugParts)).toBe(true); + }); + + it('should match Main', () => { + expect(matchSetNameToSlugParts('Main', slugParts)).toBe(true); + }); + + it('should NOT match Mainline Kickboard', () => { + expect(matchSetNameToSlugParts('Mainline Kickboard', slugParts)).toBe(false); + }); + + it('should NOT match Main Kickboard', () => { + expect(matchSetNameToSlugParts('Main Kickboard', slugParts)).toBe(false); + }); + }); + + describe('selecting only aux-kicker (not aux)', () => { + const slugParts = ['aux-kicker']; + + it('should match Auxiliary Kickboard', () => { + expect(matchSetNameToSlugParts('Auxiliary Kickboard', slugParts)).toBe(true); + }); + + it('should match Aux Kickboard', () => { + expect(matchSetNameToSlugParts('Aux Kickboard', slugParts)).toBe(true); + }); + + it('should NOT match Auxiliary', () => { + expect(matchSetNameToSlugParts('Auxiliary', slugParts)).toBe(false); + }); + + it('should NOT match Aux', () => { + expect(matchSetNameToSlugParts('Aux', slugParts)).toBe(false); + }); + }); + + describe('selecting only main-kicker (not main)', () => { + const slugParts = ['main-kicker']; + + it('should match Mainline Kickboard', () => { + expect(matchSetNameToSlugParts('Mainline Kickboard', slugParts)).toBe(true); + }); + + it('should match Main Kickboard', () => { + expect(matchSetNameToSlugParts('Main Kickboard', slugParts)).toBe(true); + }); + + it('should NOT match Mainline', () => { + expect(matchSetNameToSlugParts('Mainline', slugParts)).toBe(false); + }); + + it('should NOT match Main', () => { + expect(matchSetNameToSlugParts('Main', slugParts)).toBe(false); + }); + }); + + describe('selecting aux + main-kicker + main (no aux-kicker)', () => { + const slugParts = ['main-kicker', 'main', 'aux']; + + it('should match Auxiliary', () => { + expect(matchSetNameToSlugParts('Auxiliary', slugParts)).toBe(true); + }); + + it('should match Mainline Kickboard', () => { + expect(matchSetNameToSlugParts('Mainline Kickboard', slugParts)).toBe(true); + }); + + it('should match Mainline', () => { + expect(matchSetNameToSlugParts('Mainline', slugParts)).toBe(true); + }); + + it('should NOT match Auxiliary Kickboard', () => { + expect(matchSetNameToSlugParts('Auxiliary Kickboard', slugParts)).toBe(false); + }); + }); + }); + + describe('case insensitivity', () => { + it('should match lowercase auxiliary kickboard', () => { + expect(matchSetNameToSlugParts('auxiliary kickboard', ['aux-kicker'])).toBe(true); + }); + + it('should match uppercase AUXILIARY KICKBOARD', () => { + expect(matchSetNameToSlugParts('AUXILIARY KICKBOARD', ['aux-kicker'])).toBe(true); + }); + + it('should match mixed case AuXiLiArY KiCkBoArD', () => { + expect(matchSetNameToSlugParts('AuXiLiArY KiCkBoArD', ['aux-kicker'])).toBe(true); + }); + + it('should match lowercase aux', () => { + expect(matchSetNameToSlugParts('aux', ['aux'])).toBe(true); + }); + + it('should match uppercase AUX', () => { + expect(matchSetNameToSlugParts('AUX', ['aux'])).toBe(true); + }); + }); + + describe('whitespace handling', () => { + it('should handle leading whitespace', () => { + expect(matchSetNameToSlugParts(' Auxiliary Kickboard', ['aux-kicker'])).toBe(true); + }); + + it('should handle trailing whitespace', () => { + expect(matchSetNameToSlugParts('Auxiliary Kickboard ', ['aux-kicker'])).toBe(true); + }); + + it('should handle both leading and trailing whitespace', () => { + expect(matchSetNameToSlugParts(' Auxiliary ', ['aux'])).toBe(true); + }); + }); + + describe('original kilter/tension sets', () => { + describe('Bolt Ons', () => { + it('should match bolt slug', () => { + expect(matchSetNameToSlugParts('Bolt Ons', ['bolt'])).toBe(true); + }); + + it('should NOT match screw slug', () => { + expect(matchSetNameToSlugParts('Bolt Ons', ['screw'])).toBe(false); + }); + }); + + describe('Screw Ons', () => { + it('should match screw slug', () => { + expect(matchSetNameToSlugParts('Screw Ons', ['screw'])).toBe(true); + }); + + it('should NOT match bolt slug', () => { + expect(matchSetNameToSlugParts('Screw Ons', ['bolt'])).toBe(false); + }); + }); + + describe('bolt and screw together', () => { + const slugParts = ['screw', 'bolt']; + + it('should match Bolt Ons', () => { + expect(matchSetNameToSlugParts('Bolt Ons', slugParts)).toBe(true); + }); + + it('should match Screw Ons', () => { + expect(matchSetNameToSlugParts('Screw Ons', slugParts)).toBe(true); + }); + + it('should match bolt on (singular)', () => { + expect(matchSetNameToSlugParts('Bolt On', slugParts)).toBe(true); + }); + + it('should match screw on (singular)', () => { + expect(matchSetNameToSlugParts('Screw On', slugParts)).toBe(true); + }); + }); + }); + + describe('generic set names (fallback matching)', () => { + it('should match exact slug', () => { + expect(matchSetNameToSlugParts('Custom Set', ['custom-set'])).toBe(true); + }); + + it('should handle spaces converted to hyphens', () => { + expect(matchSetNameToSlugParts('My Custom Set', ['my-custom-set'])).toBe(true); + }); + + it('should NOT match partial slugs', () => { + expect(matchSetNameToSlugParts('Custom Set', ['custom'])).toBe(false); + }); + }); + + describe('edge cases', () => { + it('should return false for empty slug parts', () => { + expect(matchSetNameToSlugParts('Auxiliary', [])).toBe(false); + }); + + it('should return false for unmatched set name', () => { + expect(matchSetNameToSlugParts('Unknown Set', ['aux', 'main'])).toBe(false); + }); + + it('should handle set names with numbers', () => { + expect(matchSetNameToSlugParts('Set 1', ['set-1'])).toBe(true); + }); + }); + + describe('mutual exclusivity - ensuring kickboard vs non-kickboard matching', () => { + describe('aux-kicker slug should ONLY match kickboard sets', () => { + const slugParts = ['aux-kicker']; + + it('should match Auxiliary Kickboard', () => { + expect(matchSetNameToSlugParts('Auxiliary Kickboard', slugParts)).toBe(true); + }); + + it('should NOT match Auxiliary (no kickboard)', () => { + expect(matchSetNameToSlugParts('Auxiliary', slugParts)).toBe(false); + }); + + it('should NOT match Aux (no kickboard)', () => { + expect(matchSetNameToSlugParts('Aux', slugParts)).toBe(false); + }); + }); + + describe('aux slug should ONLY match non-kickboard sets', () => { + const slugParts = ['aux']; + + it('should match Auxiliary', () => { + expect(matchSetNameToSlugParts('Auxiliary', slugParts)).toBe(true); + }); + + it('should match Aux', () => { + expect(matchSetNameToSlugParts('Aux', slugParts)).toBe(true); + }); + + it('should NOT match Auxiliary Kickboard', () => { + expect(matchSetNameToSlugParts('Auxiliary Kickboard', slugParts)).toBe(false); + }); + + it('should NOT match Aux Kickboard', () => { + expect(matchSetNameToSlugParts('Aux Kickboard', slugParts)).toBe(false); + }); + }); + }); + + describe('real-world URL scenarios', () => { + describe('URL: main-kicker_main_aux-kicker_aux (full ride)', () => { + const slugParts = 'main-kicker_main_aux-kicker_aux'.split('_'); + + it('should have correct slug parts', () => { + expect(slugParts).toEqual(['main-kicker', 'main', 'aux-kicker', 'aux']); + }); + + it('should match all four homewall sets', () => { + expect(matchSetNameToSlugParts('Auxiliary Kickboard', slugParts)).toBe(true); + expect(matchSetNameToSlugParts('Mainline Kickboard', slugParts)).toBe(true); + expect(matchSetNameToSlugParts('Auxiliary', slugParts)).toBe(true); + expect(matchSetNameToSlugParts('Mainline', slugParts)).toBe(true); + }); + + it('should match abbreviated variants too', () => { + expect(matchSetNameToSlugParts('Aux Kickboard', slugParts)).toBe(true); + expect(matchSetNameToSlugParts('Main Kickboard', slugParts)).toBe(true); + expect(matchSetNameToSlugParts('Aux', slugParts)).toBe(true); + expect(matchSetNameToSlugParts('Main', slugParts)).toBe(true); + }); + }); + + describe('URL: main-kicker_main_aux (no aux-kicker)', () => { + const slugParts = 'main-kicker_main_aux'.split('_'); + + it('should match Auxiliary', () => { + expect(matchSetNameToSlugParts('Auxiliary', slugParts)).toBe(true); + }); + + it('should NOT match Auxiliary Kickboard', () => { + expect(matchSetNameToSlugParts('Auxiliary Kickboard', slugParts)).toBe(false); + }); + + it('should match Mainline Kickboard', () => { + expect(matchSetNameToSlugParts('Mainline Kickboard', slugParts)).toBe(true); + }); + + it('should match Mainline', () => { + expect(matchSetNameToSlugParts('Mainline', slugParts)).toBe(true); + }); + }); + + describe('URL: screw_bolt (original kilter sets)', () => { + const slugParts = 'screw_bolt'.split('_'); + + it('should match Bolt Ons', () => { + expect(matchSetNameToSlugParts('Bolt Ons', slugParts)).toBe(true); + }); + + it('should match Screw Ons', () => { + expect(matchSetNameToSlugParts('Screw Ons', slugParts)).toBe(true); + }); + }); + }); +}); diff --git a/app/lib/__tests__/url-utils.test.ts b/app/lib/__tests__/url-utils.test.ts index 8aeefed..4558ebb 100644 --- a/app/lib/__tests__/url-utils.test.ts +++ b/app/lib/__tests__/url-utils.test.ts @@ -440,21 +440,194 @@ describe('Slug generation functions', () => { }); describe('generateSetSlug', () => { - it('should handle homewall specific sets', () => { - expect(generateSetSlug(['Auxiliary Kickboard'])).toBe('aux-kicker'); - expect(generateSetSlug(['Mainline Kickboard'])).toBe('main-kicker'); - expect(generateSetSlug(['Auxiliary'])).toBe('aux'); - expect(generateSetSlug(['Mainline'])).toBe('main'); - }); - - it('should handle original kilter/tension sets', () => { - expect(generateSetSlug(['Bolt Ons'])).toBe('bolt'); - expect(generateSetSlug(['Screw Ons'])).toBe('screw'); - }); - - it('should sort multiple sets', () => { - const result = generateSetSlug(['bolt ons', 'screw ons']); - expect(result).toBe('screw_bolt'); + describe('homewall specific sets - full names', () => { + it('should handle Auxiliary Kickboard', () => { + expect(generateSetSlug(['Auxiliary Kickboard'])).toBe('aux-kicker'); + }); + + it('should handle Mainline Kickboard', () => { + expect(generateSetSlug(['Mainline Kickboard'])).toBe('main-kicker'); + }); + + it('should handle Auxiliary (standalone)', () => { + expect(generateSetSlug(['Auxiliary'])).toBe('aux'); + }); + + it('should handle Mainline (standalone)', () => { + expect(generateSetSlug(['Mainline'])).toBe('main'); + }); + }); + + describe('homewall specific sets - abbreviated names (Aux/Main)', () => { + it('should handle Aux Kickboard', () => { + expect(generateSetSlug(['Aux Kickboard'])).toBe('aux-kicker'); + }); + + it('should handle Main Kickboard', () => { + expect(generateSetSlug(['Main Kickboard'])).toBe('main-kicker'); + }); + + it('should handle Aux (standalone)', () => { + expect(generateSetSlug(['Aux'])).toBe('aux'); + }); + + it('should handle Main (standalone)', () => { + expect(generateSetSlug(['Main'])).toBe('main'); + }); + }); + + describe('homewall specific sets - case insensitivity', () => { + it('should handle lowercase auxiliary kickboard', () => { + expect(generateSetSlug(['auxiliary kickboard'])).toBe('aux-kicker'); + }); + + it('should handle uppercase AUXILIARY KICKBOARD', () => { + expect(generateSetSlug(['AUXILIARY KICKBOARD'])).toBe('aux-kicker'); + }); + + it('should handle mixed case AuXiLiArY', () => { + expect(generateSetSlug(['AuXiLiArY'])).toBe('aux'); + }); + + it('should handle lowercase aux', () => { + expect(generateSetSlug(['aux'])).toBe('aux'); + }); + + it('should handle uppercase AUX', () => { + expect(generateSetSlug(['AUX'])).toBe('aux'); + }); + }); + + describe('homewall specific sets - with extra whitespace', () => { + it('should handle leading/trailing whitespace', () => { + expect(generateSetSlug([' Auxiliary Kickboard '])).toBe('aux-kicker'); + expect(generateSetSlug([' Auxiliary '])).toBe('aux'); + }); + }); + + describe('homewall full ride - all four sets combined', () => { + it('should generate correct slug for all four homewall sets (full names)', () => { + const result = generateSetSlug([ + 'Auxiliary Kickboard', + 'Mainline Kickboard', + 'Auxiliary', + 'Mainline' + ]); + // Should be sorted alphabetically descending and joined with underscores + expect(result).toBe('main-kicker_main_aux-kicker_aux'); + }); + + it('should generate correct slug for all four homewall sets (abbreviated names)', () => { + const result = generateSetSlug([ + 'Aux Kickboard', + 'Main Kickboard', + 'Aux', + 'Main' + ]); + expect(result).toBe('main-kicker_main_aux-kicker_aux'); + }); + + it('should generate correct slug for mixed full and abbreviated names', () => { + const result = generateSetSlug([ + 'Auxiliary Kickboard', + 'Main Kickboard', + 'Aux', + 'Mainline' + ]); + expect(result).toBe('main-kicker_main_aux-kicker_aux'); + }); + }); + + describe('homewall partial selections', () => { + it('should handle aux + main (no kickers)', () => { + const result = generateSetSlug(['Auxiliary', 'Mainline']); + expect(result).toBe('main_aux'); + }); + + it('should handle aux-kicker + main-kicker (kickers only)', () => { + const result = generateSetSlug(['Auxiliary Kickboard', 'Mainline Kickboard']); + expect(result).toBe('main-kicker_aux-kicker'); + }); + + it('should handle aux + aux-kicker (aux variants only)', () => { + const result = generateSetSlug(['Auxiliary', 'Auxiliary Kickboard']); + expect(result).toBe('aux-kicker_aux'); + }); + + it('should handle main + main-kicker (main variants only)', () => { + const result = generateSetSlug(['Mainline', 'Mainline Kickboard']); + expect(result).toBe('main-kicker_main'); + }); + + it('should handle single aux selection', () => { + expect(generateSetSlug(['Auxiliary'])).toBe('aux'); + expect(generateSetSlug(['Aux'])).toBe('aux'); + }); + + it('should handle aux + main-kicker + main (no aux-kicker)', () => { + const result = generateSetSlug(['Auxiliary', 'Mainline Kickboard', 'Mainline']); + expect(result).toBe('main-kicker_main_aux'); + }); + }); + + describe('original kilter/tension sets', () => { + it('should handle Bolt Ons', () => { + expect(generateSetSlug(['Bolt Ons'])).toBe('bolt'); + }); + + it('should handle Screw Ons', () => { + expect(generateSetSlug(['Screw Ons'])).toBe('screw'); + }); + + it('should handle bolt on (singular)', () => { + expect(generateSetSlug(['Bolt On'])).toBe('bolt'); + }); + + it('should handle screw on (singular)', () => { + expect(generateSetSlug(['Screw On'])).toBe('screw'); + }); + + it('should sort bolt and screw correctly', () => { + const result = generateSetSlug(['Bolt Ons', 'Screw Ons']); + expect(result).toBe('screw_bolt'); + }); + }); + + describe('sorting behavior', () => { + it('should sort slugs alphabetically descending', () => { + // z > a, so 'screw' > 'main' > 'bolt' > 'aux' + const result = generateSetSlug(['Auxiliary', 'Bolt Ons', 'Mainline', 'Screw Ons']); + expect(result).toBe('screw_main_bolt_aux'); + }); + + it('should maintain consistent ordering regardless of input order', () => { + const order1 = generateSetSlug(['Auxiliary', 'Mainline', 'Auxiliary Kickboard', 'Mainline Kickboard']); + const order2 = generateSetSlug(['Mainline Kickboard', 'Auxiliary Kickboard', 'Mainline', 'Auxiliary']); + const order3 = generateSetSlug(['Auxiliary Kickboard', 'Auxiliary', 'Mainline Kickboard', 'Mainline']); + + expect(order1).toBe(order2); + expect(order2).toBe(order3); + expect(order1).toBe('main-kicker_main_aux-kicker_aux'); + }); + }); + + describe('edge cases', () => { + it('should handle empty array', () => { + expect(generateSetSlug([])).toBe(''); + }); + + it('should handle single set', () => { + expect(generateSetSlug(['Auxiliary'])).toBe('aux'); + }); + + it('should handle sets with numbers', () => { + // Generic set names should fall through to general slug generation + expect(generateSetSlug(['Set 1'])).toBe('set-1'); + }); + + it('should handle sets with special characters', () => { + expect(generateSetSlug(['Test Set!'])).toBe('test-set!'); + }); }); }); }); diff --git a/app/lib/slug-matching.ts b/app/lib/slug-matching.ts new file mode 100644 index 0000000..252bc10 --- /dev/null +++ b/app/lib/slug-matching.ts @@ -0,0 +1,40 @@ +/** + * Pure function to check if a set name matches a given slug. + * This is extracted for testability - the matching logic is complex and needs thorough testing. + * + * @param setName - The name of the set from the database + * @param slugParts - Array of slug parts (e.g., ['main-kicker', 'main', 'aux-kicker', 'aux']) + * @returns true if the set name matches any of the slug parts + */ +export const matchSetNameToSlugParts = (setName: string, slugParts: string[]): boolean => { + const lowercaseName = setName.toLowerCase().trim(); + + // Handle homewall-specific set names (supports both "Auxiliary/Mainline" and "Aux/Main" variants) + const hasAux = lowercaseName.includes('auxiliary') || lowercaseName.includes('aux'); + const hasMain = lowercaseName.includes('mainline') || lowercaseName.includes('main'); + const hasKickboard = lowercaseName.includes('kickboard'); + + // Match aux-kicker: sets with aux/auxiliary AND kickboard + if (hasAux && hasKickboard && slugParts.includes('aux-kicker')) { + return true; + } + // Match main-kicker: sets with main/mainline AND kickboard + if (hasMain && hasKickboard && slugParts.includes('main-kicker')) { + return true; + } + // Match aux: sets with aux/auxiliary but NOT kickboard + if (hasAux && !hasKickboard && slugParts.includes('aux')) { + return true; + } + // Match main: sets with main/mainline but NOT kickboard + if (hasMain && !hasKickboard && slugParts.includes('main')) { + return true; + } + + // Handle original kilter/tension set names + const setSlug = lowercaseName + .replace(/\s+ons?$/i, '') // Remove "on" or "ons" suffix + .replace(/^(bolt|screw).*/, '$1') // Extract just "bolt" or "screw" + .replace(/\s+/g, '-'); // Replace spaces with hyphens + return slugParts.includes(setSlug); +}; diff --git a/app/lib/slug-utils.ts b/app/lib/slug-utils.ts index d0f0706..b1da107 100644 --- a/app/lib/slug-utils.ts +++ b/app/lib/slug-utils.ts @@ -1,5 +1,9 @@ import { sql } from '@/app/lib/db/db'; import { BoardName, LayoutId, Size } from '@/app/lib/types'; +import { matchSetNameToSlugParts } from './slug-matching'; + +// Re-export for backwards compatibility +export { matchSetNameToSlugParts } from './slug-matching'; export type LayoutRow = { id: number; @@ -98,6 +102,15 @@ export const getSizeBySlug = async ( return size || null; }; +/** + * Parses a combined set slug and returns matching sets from the database. + * + * @param board_name - The board type (kilter, tension, etc.) + * @param layout_id - The layout ID + * @param size_id - The size ID + * @param slug - The combined slug (e.g., 'main-kicker_main_aux-kicker_aux') + * @returns Array of matching sets + */ export const getSetsBySlug = async ( board_name: BoardName, layout_id: LayoutId, @@ -107,46 +120,15 @@ export const getSetsBySlug = async ( const rows = (await sql` SELECT sets.id, sets.name FROM ${sql.unsafe(getTableName(board_name, 'sets'))} sets - INNER JOIN ${sql.unsafe(getTableName(board_name, 'product_sizes_layouts_sets'))} psls + INNER JOIN ${sql.unsafe(getTableName(board_name, 'product_sizes_layouts_sets'))} psls ON sets.id = psls.set_id WHERE psls.product_size_id = ${size_id} AND psls.layout_id = ${layout_id} `) as SetRow[]; // Parse the slug to get individual set names - const slugParts = slug.split('_'); // Split by underscore now - const matchingSets = rows.filter((s) => { - const lowercaseName = s.name.toLowerCase().trim(); - - // Handle homewall-specific set names (supports both "Auxiliary/Mainline" and "Aux/Main" variants) - const hasAux = lowercaseName.includes('auxiliary') || lowercaseName.includes('aux'); - const hasMain = lowercaseName.includes('mainline') || lowercaseName.includes('main'); - const hasKickboard = lowercaseName.includes('kickboard'); - - // Match aux-kicker: sets with aux/auxiliary AND kickboard - if (hasAux && hasKickboard && slugParts.includes('aux-kicker')) { - return true; - } - // Match main-kicker: sets with main/mainline AND kickboard - if (hasMain && hasKickboard && slugParts.includes('main-kicker')) { - return true; - } - // Match aux: sets with aux/auxiliary but NOT kickboard - if (hasAux && !hasKickboard && slugParts.includes('aux')) { - return true; - } - // Match main: sets with main/mainline but NOT kickboard - if (hasMain && !hasKickboard && slugParts.includes('main')) { - return true; - } - - // Handle original kilter/tension set names - const setSlug = lowercaseName - .replace(/\s+ons?$/i, '') // Remove "on" or "ons" suffix - .replace(/^(bolt|screw).*/, '$1') // Extract just "bolt" or "screw" - .replace(/\s+/g, '-'); // Replace spaces with hyphens - return slugParts.includes(setSlug); - }); + const slugParts = slug.split('_'); + const matchingSets = rows.filter((s) => matchSetNameToSlugParts(s.name, slugParts)); return matchingSets; }; From a495532079e523d99b5797e0580baeca81f5083d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Nov 2025 11:14:43 +0000 Subject: [PATCH 5/5] Fix auxiliary holds URL parsing for homewall 10x12 size The 10x12 homewall size uses different naming for set types - some use "kicker" instead of "kickboard". Updated both URL generation and parsing to handle both variants: - generateSetSlug now checks for both "kickboard" and "kicker" - matchSetNameToSlugParts now checks for both variants - Added comprehensive tests for all naming variations This ensures "Aux Kicker" matches 'aux-kicker' slug just like "Auxiliary Kickboard" does. --- app/lib/__tests__/slug-utils.test.ts | 62 ++++++++++++++++++++++++++++ app/lib/__tests__/url-utils.test.ts | 28 +++++++++++++ app/lib/slug-matching.ts | 19 +++++---- app/lib/url-utils.ts | 7 ++-- 4 files changed, 104 insertions(+), 12 deletions(-) diff --git a/app/lib/__tests__/slug-utils.test.ts b/app/lib/__tests__/slug-utils.test.ts index 82da497..7f29618 100644 --- a/app/lib/__tests__/slug-utils.test.ts +++ b/app/lib/__tests__/slug-utils.test.ts @@ -118,6 +118,68 @@ describe('matchSetNameToSlugParts', () => { }); }); + describe('homewall sets - "kicker" naming variant (used in some sizes like 10x12)', () => { + describe('Aux Kicker (without "board")', () => { + it('should match aux-kicker slug', () => { + expect(matchSetNameToSlugParts('Aux Kicker', ['aux-kicker'])).toBe(true); + }); + + it('should NOT match aux slug', () => { + expect(matchSetNameToSlugParts('Aux Kicker', ['aux'])).toBe(false); + }); + }); + + describe('Main Kicker (without "board")', () => { + it('should match main-kicker slug', () => { + expect(matchSetNameToSlugParts('Main Kicker', ['main-kicker'])).toBe(true); + }); + + it('should NOT match main slug', () => { + expect(matchSetNameToSlugParts('Main Kicker', ['main'])).toBe(false); + }); + }); + + describe('Auxiliary Kicker (full name without "board")', () => { + it('should match aux-kicker slug', () => { + expect(matchSetNameToSlugParts('Auxiliary Kicker', ['aux-kicker'])).toBe(true); + }); + + it('should NOT match aux slug', () => { + expect(matchSetNameToSlugParts('Auxiliary Kicker', ['aux'])).toBe(false); + }); + }); + + describe('Mainline Kicker (full name without "board")', () => { + it('should match main-kicker slug', () => { + expect(matchSetNameToSlugParts('Mainline Kicker', ['main-kicker'])).toBe(true); + }); + + it('should NOT match main slug', () => { + expect(matchSetNameToSlugParts('Mainline Kicker', ['main'])).toBe(false); + }); + }); + + describe('10x12 full ride with kicker naming', () => { + const fullRideSlugParts = ['main-kicker', 'main', 'aux-kicker', 'aux']; + + it('should match Aux Kicker to aux-kicker', () => { + expect(matchSetNameToSlugParts('Aux Kicker', fullRideSlugParts)).toBe(true); + }); + + it('should match Main Kicker to main-kicker', () => { + expect(matchSetNameToSlugParts('Main Kicker', fullRideSlugParts)).toBe(true); + }); + + it('should match Aux to aux', () => { + expect(matchSetNameToSlugParts('Aux', fullRideSlugParts)).toBe(true); + }); + + it('should match Main to main', () => { + expect(matchSetNameToSlugParts('Main', fullRideSlugParts)).toBe(true); + }); + }); + }); + describe('homewall full ride - all four sets with full slug', () => { const fullRideSlugParts = ['main-kicker', 'main', 'aux-kicker', 'aux']; diff --git a/app/lib/__tests__/url-utils.test.ts b/app/lib/__tests__/url-utils.test.ts index 4558ebb..8f3c29f 100644 --- a/app/lib/__tests__/url-utils.test.ts +++ b/app/lib/__tests__/url-utils.test.ts @@ -505,6 +505,34 @@ describe('Slug generation functions', () => { }); }); + describe('homewall specific sets - "kicker" naming variant (used in some sizes like 10x12)', () => { + it('should handle Aux Kicker (without "board")', () => { + expect(generateSetSlug(['Aux Kicker'])).toBe('aux-kicker'); + }); + + it('should handle Main Kicker (without "board")', () => { + expect(generateSetSlug(['Main Kicker'])).toBe('main-kicker'); + }); + + it('should handle Auxiliary Kicker', () => { + expect(generateSetSlug(['Auxiliary Kicker'])).toBe('aux-kicker'); + }); + + it('should handle Mainline Kicker', () => { + expect(generateSetSlug(['Mainline Kicker'])).toBe('main-kicker'); + }); + + it('should generate correct slug for 10x12 with kicker naming', () => { + const result = generateSetSlug([ + 'Aux Kicker', + 'Main Kicker', + 'Aux', + 'Main' + ]); + expect(result).toBe('main-kicker_main_aux-kicker_aux'); + }); + }); + describe('homewall full ride - all four sets combined', () => { it('should generate correct slug for all four homewall sets (full names)', () => { const result = generateSetSlug([ diff --git a/app/lib/slug-matching.ts b/app/lib/slug-matching.ts index 252bc10..f3be6eb 100644 --- a/app/lib/slug-matching.ts +++ b/app/lib/slug-matching.ts @@ -12,22 +12,23 @@ export const matchSetNameToSlugParts = (setName: string, slugParts: string[]): b // Handle homewall-specific set names (supports both "Auxiliary/Mainline" and "Aux/Main" variants) const hasAux = lowercaseName.includes('auxiliary') || lowercaseName.includes('aux'); const hasMain = lowercaseName.includes('mainline') || lowercaseName.includes('main'); - const hasKickboard = lowercaseName.includes('kickboard'); + // Support both "kickboard" and "kicker" in set names (different sizes use different naming) + const hasKickerVariant = lowercaseName.includes('kickboard') || lowercaseName.includes('kicker'); - // Match aux-kicker: sets with aux/auxiliary AND kickboard - if (hasAux && hasKickboard && slugParts.includes('aux-kicker')) { + // Match aux-kicker: sets with aux/auxiliary AND kickboard/kicker + if (hasAux && hasKickerVariant && slugParts.includes('aux-kicker')) { return true; } - // Match main-kicker: sets with main/mainline AND kickboard - if (hasMain && hasKickboard && slugParts.includes('main-kicker')) { + // Match main-kicker: sets with main/mainline AND kickboard/kicker + if (hasMain && hasKickerVariant && slugParts.includes('main-kicker')) { return true; } - // Match aux: sets with aux/auxiliary but NOT kickboard - if (hasAux && !hasKickboard && slugParts.includes('aux')) { + // Match aux: sets with aux/auxiliary but NOT kickboard/kicker + if (hasAux && !hasKickerVariant && slugParts.includes('aux')) { return true; } - // Match main: sets with main/mainline but NOT kickboard - if (hasMain && !hasKickboard && slugParts.includes('main')) { + // Match main: sets with main/mainline but NOT kickboard/kicker + if (hasMain && !hasKickerVariant && slugParts.includes('main')) { return true; } diff --git a/app/lib/url-utils.ts b/app/lib/url-utils.ts index f84075b..a383483 100644 --- a/app/lib/url-utils.ts +++ b/app/lib/url-utils.ts @@ -337,12 +337,13 @@ export const generateSetSlug = (setNames: string[]): string => { // Handle homewall-specific set names (supports both "Auxiliary/Mainline" and "Aux/Main" variants) const hasAux = lowercaseName.includes('auxiliary') || lowercaseName.includes('aux'); const hasMain = lowercaseName.includes('mainline') || lowercaseName.includes('main'); - const hasKickboard = lowercaseName.includes('kickboard'); + // Support both "kickboard" and "kicker" in set names (different sizes use different naming) + const hasKickerVariant = lowercaseName.includes('kickboard') || lowercaseName.includes('kicker'); - if (hasAux && hasKickboard) { + if (hasAux && hasKickerVariant) { return 'aux-kicker'; } - if (hasMain && hasKickboard) { + if (hasMain && hasKickerVariant) { return 'main-kicker'; } if (hasAux) {