diff --git a/api/migrations/0009_tag_normalized_type.sql b/api/migrations/0009_tag_normalized_type.sql new file mode 100644 index 0000000..47672cb --- /dev/null +++ b/api/migrations/0009_tag_normalized_type.sql @@ -0,0 +1,4 @@ +-- Add normalized_tournament_type column to tags table +-- This allows tags to normalize all tournaments to a single type for points calculation + +ALTER TABLE tags ADD COLUMN normalized_tournament_type TEXT; diff --git a/api/package.json b/api/package.json index ef2a935..1785b4c 100644 --- a/api/package.json +++ b/api/package.json @@ -26,23 +26,23 @@ "p-limit": "^4.0.0", "toucan-js": "^3.4.0", "unique-names-generator": "^4.7.1", - "zod": "^3.24.1" + "zod": "^3.25.76" }, "devDependencies": { - "@sentry/cli": "^2.40.0", - "@cloudflare/workers-types": "^4.20241230.0", + "@cloudflare/workers-types": "^4.20251121.0", "@jest/types": "^29.6.3", + "@sentry/cli": "^2.58.2", "@types/jest": "^29.5.14", - "@types/node": "^20.17.11", + "@types/node": "^20.19.25", "@types/object-hash": "^3.0.6", "@types/react-syntax-highlighter": "^15.5.13", - "eslint-plugin-import": "^2.31.0", + "eslint-plugin-import": "^2.32.0", "jest": "^29.7.0", "kysely": "^0.26.3", - "miniflare": "^3.20241218.0", - "ts-jest": "^29.2.5", + "miniflare": "^3.20250718.2", + "ts-jest": "^29.4.5", "ts-node": "^10.9.2", - "typescript": "^5.7.2", - "wrangler": "^3.99.0" + "typescript": "^5.9.3", + "wrangler": "^3.114.15" } } \ No newline at end of file diff --git a/api/src/lib/ranking.ts b/api/src/lib/ranking.ts index 7a1cc1d..fa5c503 100644 --- a/api/src/lib/ranking.ts +++ b/api/src/lib/ranking.ts @@ -14,6 +14,7 @@ export enum Tournament { CircuitBreakerInvitational = "circuit breaker invitational", PlayersCircuit = "players circuit", Intercontinental = "intercontinental championship", + CommunityTournament = "community tournament", } // Default configuration (Season 0) @@ -32,6 +33,7 @@ export const DEFAULT_CONFIG: Record> = { [Tournament.CasualTournamentKit]: 1, [Tournament.DistrictChampionship]: 1.2, [Tournament.MegaCityChampionship]: 1.5, + [Tournament.CommunityTournament]: 1, }, // Flat points added to the total point pool that gets awarded to 1st place // Each tournament gets a different point total to reflect the tournament prestige @@ -47,6 +49,7 @@ export const DEFAULT_CONFIG: Record> = { [Tournament.CasualTournamentKit]: 15, [Tournament.DistrictChampionship]: 0, [Tournament.MegaCityChampionship]: 0, + [Tournament.CommunityTournament]: 0, }, // Sets a baseline number of players a tournament must have in order to distribute any points at all // This means that small tournaments are not eligible for payouts @@ -62,6 +65,7 @@ export const DEFAULT_CONFIG: Record> = { [Tournament.CasualTournamentKit]: 8, [Tournament.DistrictChampionship]: 8, [Tournament.MegaCityChampionship]: 8, + [Tournament.CommunityTournament]: 8, }, // Defines the max number of tournaments a person can get points for // We take the top values if a person attends more than the defined max @@ -77,6 +81,7 @@ export const DEFAULT_CONFIG: Record> = { [Tournament.CasualTournamentKit]: 5, [Tournament.DistrictChampionship]: 3, [Tournament.MegaCityChampionship]: 2, + [Tournament.CommunityTournament]: 1, }, // Defines the bottom anchor point which means the last place player will receive less than the value provided // This is used to help set the rate of decay and the payout slope. A higher number indicates a more gradual slope @@ -92,6 +97,7 @@ export const DEFAULT_CONFIG: Record> = { [Tournament.CasualTournamentKit]: 1, [Tournament.DistrictChampionship]: 1, [Tournament.MegaCityChampionship]: 1, + [Tournament.CommunityTournament]: 1, }, }; diff --git a/api/src/models/results.ts b/api/src/models/results.ts index c54ea56..a0ee77f 100644 --- a/api/src/models/results.ts +++ b/api/src/models/results.ts @@ -1,5 +1,5 @@ import { g } from "../g.js"; -import { DEFAULT_CONFIG } from "../lib/ranking.js"; +import { DEFAULT_CONFIG, calculatePointDistribution } from "../lib/ranking.js"; import { traceDeco } from "../lib/tracer.js"; import type { Faction, @@ -18,8 +18,10 @@ export type ResultExpanded = ResultsTable & { disabled: number; format: Format; tournament_type: TournamentType; + season_id: number | null; count_for_tournament_type: number; is_valid: boolean; + normalized_tournament_type_used?: TournamentType | null; }; type GetExpandedOptions = { @@ -83,6 +85,7 @@ export class Results { "tournaments.name as tournament_name", "tournaments.players_count as players_count", "tournaments.format as format", + "tournaments.season_id as season_id", ]) // Only fetch results for non-disabled users .where("users.disabled", "=", 0) @@ -130,6 +133,11 @@ export class Results { includeLimits = true; } + // Check if any tag has a normalized tournament type + const normalizedType = tagModels.find( + (tag) => tag.normalized_tournament_type != null, + )?.normalized_tournament_type; + // TODO: yes we could do this in sql, but this is so much easier const results: ResultExpanded[] = []; const initialResults = await q.execute(); @@ -138,12 +146,36 @@ export class Results { DEFAULT_CONFIG.MAX_TOURNAMENTS_PER_TYPE[ initialResults[i].tournament_type ]; - results.push({ + + const resultToAdd: ResultExpanded = { ...initialResults[i], is_valid: includeLimits ? initialResults[i].count_for_tournament_type <= max : true, - }); + normalized_tournament_type_used: normalizedType + ? (normalizedType as TournamentType) + : null, + }; + + // Recalculate points if a normalized tournament type is set + if (normalizedType) { + const { points } = calculatePointDistribution( + initialResults[i].players_count, + normalizedType as TournamentType, + undefined, + initialResults[i].season_id ?? undefined, + ); + + // Determine placement index (same logic as ingestion) + const placementIndex = + (initialResults[i].rank_cut || initialResults[i].rank_swiss) - 1; + + if (placementIndex >= 0 && placementIndex < points.length) { + resultToAdd.points_earned = points[placementIndex]; + } + } + + results.push(resultToAdd); } const sortedResults: ResultExpanded[] = []; diff --git a/api/src/openapi.ts b/api/src/openapi.ts index 8b2c2d6..9e9591a 100644 --- a/api/src/openapi.ts +++ b/api/src/openapi.ts @@ -73,6 +73,8 @@ export const ResultComponent = z format: FormatComponent, count_for_tournament_type: z.number().default(0), is_valid: z.boolean(), + normalized_tournament_type_used: + TournamentTypeComponent.nullable().optional(), }) .openapi("Result"); export type ResultComponentType = z.infer; @@ -205,6 +207,7 @@ export const GetTagsResponseComponent = z owner_name: z.string(), count: z.number(), use_tournament_limits: z.coerce.boolean(), + normalized_tournament_type: z.string().nullable().optional(), }) .openapi("GetTagsResponse"); export type GetTagsResponseComponentType = z.infer< @@ -218,6 +221,7 @@ export const TagComponent = z normalized: z.string(), owner_id: z.number(), use_tournament_limits: z.coerce.boolean(), + normalized_tournament_type: z.string().nullable().optional(), }) .openapi("Tag"); export type TagComponentType = z.infer; @@ -485,6 +489,7 @@ export const InsertTagSchema = { export const UpdateTagBody = z.object({ use_tournament_limits: z.boolean(), + normalized_tournament_type: z.string().nullable().optional(), }); export type UpdateTagBodyType = z.infer; diff --git a/api/src/routes/tags.ts b/api/src/routes/tags.ts index e857eed..702840b 100644 --- a/api/src/routes/tags.ts +++ b/api/src/routes/tags.ts @@ -119,6 +119,11 @@ export class UpdateTag extends OpenAPIRoute { } tag.use_tournament_limits = body.use_tournament_limits ? 1 : 0; + // Update normalized_tournament_type if provided in the body + if (body.normalized_tournament_type !== undefined) { + tag.normalized_tournament_type = body.normalized_tournament_type; + } + try { const updatedTag = await Tags.update(tag); return json(TagComponent.parse(updatedTag)); diff --git a/api/src/schema.ts b/api/src/schema.ts index 3549880..7a4ffc3 100644 --- a/api/src/schema.ts +++ b/api/src/schema.ts @@ -118,6 +118,7 @@ export interface TagsTable { normalized: string; owner_id: number; use_tournament_limits: number; + normalized_tournament_type: string | null; } export type Tag = Selectable; diff --git a/app/package.json b/app/package.json index 266b8a6..f56d926 100644 --- a/app/package.json +++ b/app/package.json @@ -13,37 +13,37 @@ "author": "", "license": "ISC", "devDependencies": { - "@cloudflare/workers-types": "^4.20241230.0", + "@cloudflare/workers-types": "^4.20251121.0", "@svgr/webpack": "^8.1.0", - "@tailwindcss/typography": "^0.5.15", + "@tailwindcss/typography": "^0.5.19", "@types/react": "18.2.0", "@types/react-dom": "18.2.0", - "autoprefixer": "^10.4.20", - "axios": "^1.7.9", + "autoprefixer": "^10.4.22", + "axios": "^1.13.2", "babel-plugin-named-exports-order": "^0.0.2", "file-loader": "^6.2.0", - "form-data": "^4.0.1", - "postcss": "^8.4.49", + "form-data": "^4.0.5", + "postcss": "^8.5.6", "postcss-loader": "^7.3.4", "prop-types": "^15.8.1", "react-scripts": "^5.0.1", "tailwind-scrollbar": "^3.1.0", - "tailwindcss": "^3.4.17", + "tailwindcss": "^3.4.18", "typescript": "^4.9.5", - "webpack": "^5.97.1", - "wrangler": "^3.99.0" + "webpack": "^5.103.0", + "wrangler": "^3.114.15" }, "dependencies": { "@floating-ui/react": "^0.26.28", - "@fontsource/inter": "^5.1.1", - "@fontsource/jetbrains-mono": "^5.1.2", + "@fontsource/inter": "^5.2.8", + "@fontsource/jetbrains-mono": "^5.2.8", "@fortawesome/fontawesome-svg-core": "^6.7.2", "@fortawesome/free-solid-svg-icons": "^6.7.2", - "@fortawesome/react-fontawesome": "^0.2.2", + "@fortawesome/react-fontawesome": "^0.2.6", "@headlessui/react": "^1.7.19", "@heroicons/react": "^2.2.0", - "@tanstack/react-query": "^5.62.14", - "@types/node": "^20.17.11", + "@tanstack/react-query": "^5.90.10", + "@types/node": "^20.19.25", "@visx/axis": "^3.12.0", "@visx/curve": "^3.12.0", "@visx/event": "^3.12.0", @@ -63,9 +63,9 @@ "moment": "^2.30.1", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-router-dom": "^6.28.1", - "react-syntax-highlighter": "^15.6.1", - "recharts": "^2.15.0", + "react-router-dom": "^6.30.2", + "react-syntax-highlighter": "^15.6.6", + "recharts": "^2.15.4", "tailwind-merge": "^2.6.0", "web-vitals": "^2.1.4" }, diff --git a/app/src/client/models/GetTagsResponse.ts b/app/src/client/models/GetTagsResponse.ts index accfc92..af9c42e 100644 --- a/app/src/client/models/GetTagsResponse.ts +++ b/app/src/client/models/GetTagsResponse.ts @@ -11,5 +11,6 @@ export type GetTagsResponse = { owner_name: string; count: number; use_tournament_limits?: boolean | null; + normalized_tournament_type?: string | null; }; diff --git a/app/src/client/models/Result.ts b/app/src/client/models/Result.ts index b9b2ba7..7c846b7 100644 --- a/app/src/client/models/Result.ts +++ b/app/src/client/models/Result.ts @@ -28,5 +28,6 @@ export type Result = { format: (Format & string); count_for_tournament_type?: number; is_valid: boolean; + normalized_tournament_type_used?: (TournamentType & string | null); }; diff --git a/app/src/client/models/Tag.ts b/app/src/client/models/Tag.ts index 8bd0630..237b652 100644 --- a/app/src/client/models/Tag.ts +++ b/app/src/client/models/Tag.ts @@ -9,5 +9,6 @@ export type Tag = { normalized: string; owner_id: number; use_tournament_limits?: boolean | null; + normalized_tournament_type?: string | null; }; diff --git a/app/src/client/services/TagsService.ts b/app/src/client/services/TagsService.ts index 025ff4c..24547f7 100644 --- a/app/src/client/services/TagsService.ts +++ b/app/src/client/services/TagsService.ts @@ -78,6 +78,7 @@ export class TagsService { tagId: number, requestBody?: { use_tournament_limits: boolean; + normalized_tournament_type?: string | null; }, ): CancelablePromise { return __request(OpenAPI, { diff --git a/app/src/output.css b/app/src/output.css index e38c994..0907476 100644 --- a/app/src/output.css +++ b/app/src/output.css @@ -107,7 +107,7 @@ } /* -! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com +! tailwindcss v3.4.18 | MIT License | https://tailwindcss.com */ /* @@ -1097,6 +1097,10 @@ html { grid-template-columns: repeat(1, minmax(0, 1fr)); } +.grid-cols-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + .flex-row { flex-direction: row; } @@ -1109,6 +1113,10 @@ html { align-items: center; } +.justify-end { + justify-content: flex-end; +} + .justify-center { justify-content: center; } @@ -1234,6 +1242,10 @@ html { border-right-width: 1px; } +.border-t { + border-top-width: 1px; +} + .border-solid { border-style: solid; } @@ -1454,6 +1466,10 @@ html { padding-top: 0.5rem; } +.pt-3 { + padding-top: 0.75rem; +} + .pt-4 { padding-top: 1rem; } @@ -1564,6 +1580,11 @@ html { color: rgb(107 114 128 / var(--tw-text-opacity, 1)); } +.text-gray-600 { + --tw-text-opacity: 1; + color: rgb(75 85 99 / var(--tw-text-opacity, 1)); +} + .text-gray-900 { --tw-text-opacity: 1; color: rgb(17 24 39 / var(--tw-text-opacity, 1)); @@ -1574,6 +1595,11 @@ html { color: rgb(3 7 18 / var(--tw-text-opacity, 1)); } +.text-red-600 { + --tw-text-opacity: 1; + color: rgb(220 38 38 / var(--tw-text-opacity, 1)); +} + .text-red-700 { --tw-text-opacity: 1; color: rgb(185 28 28 / var(--tw-text-opacity, 1)); @@ -1594,6 +1620,11 @@ html { color: rgb(255 255 255 / var(--tw-text-opacity, 1)); } +.text-yellow-400 { + --tw-text-opacity: 1; + color: rgb(250 204 21 / var(--tw-text-opacity, 1)); +} + .underline { -webkit-text-decoration-line: underline; text-decoration-line: underline; @@ -1813,6 +1844,10 @@ html { background-color: rgb(55 65 81 / var(--tw-bg-opacity, 1)); } +.hover\:bg-red-900\/20:hover { + background-color: rgb(127 29 29 / 0.2); +} + .hover\:font-bold:hover { font-weight: 700; } @@ -1985,6 +2020,10 @@ html { display: block; } + .md\:table { + display: table; + } + .md\:hidden { display: none; } diff --git a/app/src/routes/Profile.tsx b/app/src/routes/Profile.tsx index 98b2fb8..72c6c3c 100644 --- a/app/src/routes/Profile.tsx +++ b/app/src/routes/Profile.tsx @@ -10,6 +10,7 @@ import { TagsService, type Tournament, TournamentService, + TournamentType, type User, UserService, } from "../client"; @@ -247,6 +248,18 @@ export function Profile() { const handleTagSwitchChange = async (tag: GetTagsResponse) => { await TagsService.postUpdateTag(tag.id, { use_tournament_limits: !tag.use_tournament_limits, + normalized_tournament_type: tag.normalized_tournament_type, + }); + await refetch(); + }; + + const handleNormalizedTypeChange = async ( + tag: GetTagsResponse, + newType: string | null, + ) => { + await TagsService.postUpdateTag(tag.id, { + use_tournament_limits: tag.use_tournament_limits || false, + normalized_tournament_type: newType, }); await refetch(); }; @@ -324,7 +337,7 @@ export function Profile() { className={"my-4"} /> -
+
+ {/* Mobile view - Cards */} +
+ {tags?.map((tag) => ( +
+ {/* Header Row */} +
+ + + {tag.count} tournament{tag.count !== 1 ? "s" : ""} + +
+ + {/* Two Column Grid */} +
+ {/* Left Column - Tournament Limits */} +
+ +
+ handleTagSwitchChange(tag)} + className={ + "relative inline-flex h-6 w-12 items-center rounded-full border-2 border-gray-600 bg-gray-900" + } + disabled={user === null} + > + + +
+
+ + {/* Right Column - Normalize Type */} +
+ + +
+
+ + {/* Footer Row - Delete Button */} +
+ + + + + {tag.count !== 0 && ( + path:first-of-type]:stroke-gray-600" + } + > + Cannot delete a tag that has tournaments + + )} + +
+
+ ))} +
+ + {/* Desktop view - Table */} @@ -366,6 +502,12 @@ export function Profile() { > Use Tournament Limits + +