diff --git a/README.md b/README.md index 169ff14..dd9808c 100644 --- a/README.md +++ b/README.md @@ -12,14 +12,27 @@ An unofficial, local-first fantasy football draft and team-management assistant - Connects to Sleeper's tokenless, read-only API by username, league, or draft. - Tracks live and completed draft boards. - Produces deterministic recommendations from roster construction, scarcity, availability, ADP, tiers, and other explainable signals. -- Imports a FantasyPros rankings CSV that you download yourself; no ranking data is bundled or redistributed. -- Imports user-downloaded FantasyPros weekly projection CSVs by position for lineup and waiver analysis. +- Imports user-downloaded FantasyPros draft rankings, season projections, and Sleeper ADP exports; no third-party data is bundled or redistributed. +- Imports user-downloaded FantasyPros overall rest-of-season rankings and weekly projection CSVs for team-management analysis. - Shows weekly data readiness, current-versus-optimized lineup totals, roster needs, waiver context, weekly context, and league activity. - Refreshes visible Team Manager data from Sleeper every 60 seconds and when the app regains focus. - Offers an optional local Codex app-server provider for conversational analysis. - Stores settings, imported rankings and projections, and decision history locally in SQLite. -Sleeper is currently the only supported fantasy platform. Sleeper search rank is a low-confidence fallback; import current rankings before relying on real-draft recommendations. +Sleeper is currently the only supported fantasy platform. Sleeper search rank is a low-confidence fallback; import current draft data before relying on real-draft recommendations. + +## League format support + +| Format | Alpha support | +| --- | --- | +| Standard, half-PPR, and PPR redraft | Supported | +| FLEX and superflex roster construction | Supported | +| Custom scoring and TE premium | Limited; the app warns when imported values may not match | +| IDP | Unsupported; defensive players are excluded from recommendations | +| Auction/salary drafts | Unsupported; budgets and nomination strategy are not modeled | +| Dynasty and keeper valuation | Not yet modeled as a dedicated strategy | + +Roster slots, team count, rounds, and supported scoring settings come from Sleeper. FantasyPros ECR and rest-of-season imports must match the league scoring format. Weekly `FPTS` are provider-scored, so users must export FantasyPros projections using the same scoring format as their Sleeper league. ## Try the demo in about a minute @@ -39,9 +52,12 @@ Open `http://127.0.0.1:5173`, then choose **Load demo draft**. The demo uses syn 1. Enter your Sleeper username or user ID. 2. If needed, paste a Sleeper league URL or league ID. 3. Select the draft and confirm your team or draft slot. -4. Export rankings for your scoring format from FantasyPros and import the CSV in the app. -5. During the season, export the six weekly projection files for QB, RB, WR, TE, K, and DST, then import them together from Team Manager. -6. Review the recommendation evidence before making a pick. +4. Export rankings for your scoring format from FantasyPros and import the CSV as the ECR and tier signal. +5. Export the season projection files for QB, RB, WR, TE, K, and DST and import them together. The FLX file is not needed because it duplicates players from RB, WR, and TE. +6. Export FantasyPros Overall ADP and import it for the Sleeper and Real-Time market columns. A separate Real-Time ADP download is not required. +7. During the season, export the overall rest-of-season rankings for your scoring format and import the single CSV from Team Manager. +8. Export the six weekly projection files for QB, RB, WR, TE, K, and DST, then import them together from Team Manager. +9. Review the recommendation evidence before making a pick or changing your team. The app reads Sleeper data but does not submit picks, change lineups, or modify your Sleeper account. @@ -67,7 +83,7 @@ See [Installing on Windows](docs/INSTALLING.md) for artifact choices, checksum v ## Local data and privacy -The packaged app stores data beneath Electron's per-user application-data directory. Development uses `data/` in the repository unless `SLEEPER_AI_DATA_DIR` is set. Stored data can include league and draft identifiers, imported rankings and weekly projections, settings, and recommendation history. +The packaged app stores data beneath Electron's per-user application-data directory. Development uses `data/` in the repository unless `SLEEPER_AI_DATA_DIR` is set. Stored data can include league and draft identifiers, imported draft and rest-of-season rankings, season and weekly projections, ADP, settings, and recommendation history. Settings shows aggregate local-data counts, can download a redacted support report, and provides controls to clear imports, recommendation history, or all local app data. diff --git a/apps/api/package.json b/apps/api/package.json index 4fa0f78..1ac6aec 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,6 +1,6 @@ { "name": "@sleeper-draft-assistant/api", - "version": "0.1.0-alpha.3", + "version": "0.1.0-alpha.4", "private": true, "type": "module", "scripts": { @@ -11,8 +11,8 @@ }, "dependencies": { "@hono/node-server": "^2.0.12", - "@sleeper-draft-assistant/engine": "0.1.0-alpha.3", - "@sleeper-draft-assistant/shared": "0.1.0-alpha.3", + "@sleeper-draft-assistant/engine": "0.1.0-alpha.4", + "@sleeper-draft-assistant/shared": "0.1.0-alpha.4", "hono": "^4.10.7", "sql.js": "^1.14.1" }, diff --git a/apps/api/src/ai/context.ts b/apps/api/src/ai/context.ts index 63bd0ce..97c5442 100644 --- a/apps/api/src/ai/context.ts +++ b/apps/api/src/ai/context.ts @@ -17,9 +17,13 @@ export function buildDraftAiContext( const candidateSources = new Set(recommendation.candidates.map((candidate) => candidate.player.projectionSource)); const dataQuality: DraftAiContext["dataQuality"] = { playerValueSource: describePlayerValueSource(candidateSources), - hasImportedRankings: candidateSources.has("imported"), + hasImportedRankings: recommendation.candidates.some((candidate) => candidate.player.importedRank !== null && candidate.player.importedRank !== undefined), + hasSeasonProjections: candidateSources.has("season_projection"), usesSleeperPlaceholderRanks: candidateSources.has("sleeper_search_rank"), - limitations: getDataLimitations(candidateSources), + limitations: [ + ...getDataLimitations(candidateSources), + ...(state.settings.formatCompatibility?.warnings ?? []), + ], }; const rosterConstruction = buildRosterConstruction(state, userTeam, playersById); const userTeamSummary: DraftAiContext["userTeam"] = userTeam @@ -56,6 +60,9 @@ export function buildDraftAiContext( reasons: candidate.reasons, source: candidate.player.projectionSource, importedRank: candidate.player.importedRank ?? null, + seasonProjectedPoints: candidate.player.seasonProjectedPoints ?? null, + sleeperAdp: candidate.player.adpSource ? candidate.player.adp : null, + realTimeAdp: candidate.player.realTimeAdp ?? null, tier: candidate.player.tier, byeWeek: candidate.player.byeWeek ?? null, riskTags: candidate.player.riskTags, @@ -153,7 +160,9 @@ function buildPrimaryDecisionGuidance( guidance.push("QB replacement pressure is lower because this is not a superflex format."); } - if (dataQuality.hasImportedRankings) { + if (dataQuality.hasSeasonProjections) { + guidance.push("Season projections provide the primary point and replacement-value signal; imported rankings remain a separate expert-opinion signal."); + } else if (dataQuality.hasImportedRankings) { guidance.push("Imported rankings can support board value and tier decisions, but they are not a full projection model."); } else if (dataQuality.usesSleeperPlaceholderRanks) { guidance.push("Sleeper placeholder ranks are scaffolding only, so avoid overconfident advice."); @@ -264,6 +273,10 @@ function countRosterPositions( } function describePlayerValueSource(sources: Set): string { + if (sources.has("season_projection")) { + return "FantasyPros season projections are scored against the connected Sleeper format; ECR and Sleeper ADP remain separate expert and market signals."; + } + if (sources.has("imported")) { return "Imported rankings are the primary player-value signal. Treat them as rankings/scaffolding unless true projections are imported later."; } @@ -290,6 +303,13 @@ function getDataLimitations(sources: Set): string[] ]; } + if (sources.has("season_projection")) { + return [ + "Season projections cover expected volume, not injuries, depth-chart changes, or live news.", + "Kicker and defense scoring may be approximate when exports lack field-goal distance or per-game points-allowed detail.", + ]; + } + return ["Demo data is useful for UI testing only."]; } diff --git a/apps/api/src/ai/team-context.ts b/apps/api/src/ai/team-context.ts index fbea33a..15a647e 100644 --- a/apps/api/src/ai/team-context.ts +++ b/apps/api/src/ai/team-context.ts @@ -43,6 +43,7 @@ function buildTeamBrief(state: TeamManagerState, teamNeeds: TeamNeedsSummary, we ); const benchPlayers = state.roster.bench.map((player) => `${player.name} (${player.team} ${player.position})`); const hasWeeklyProjections = teamHasWeeklyProjections(state) || waiverSummary.candidates.some((candidate) => candidate.player.projectionSource === "weekly_projection"); + const hasRosRankings = teamHasRosRankings(state) || waiverSummary.candidates.some((candidate) => Boolean(candidate.player.rosRank)); return { leagueFormat: `${state.league.teams}-team ${state.league.scoring}, slots ${formatRosterSlots(state.league.rosterSlots)}`, @@ -78,6 +79,9 @@ function buildTeamBrief(state: TeamManagerState, teamNeeds: TeamNeedsSummary, we : "Do not invent projections, injuries, waiver-wire availability, or player news.", "Use weekContext only for current Sleeper matchup, lineup, and score state; do not treat it as projections.", "For start/sit and lineup optimization questions, use lineupSummary and lineupDecisions before general roster-shape advice.", + hasRosRankings + ? "For adds, drops, and stashes, use rest-of-season rank as the long-term value signal and weekly projections as the immediate-week signal." + : "For adds, drops, and stashes, state that current rest-of-season rankings are unavailable.", "Use dataReadinessFacts to qualify confidence; never present incomplete or mismatched projection data as current.", "For add/drop questions, use waiverSummary, activitySummary, topWaiverCandidates, and trendingAdds before general roster-shape advice.", "If a starter slot is open, prioritize filling that slot before bench-upgrade advice.", @@ -96,6 +100,15 @@ function teamHasWeeklyProjections(state: TeamManagerState): boolean { ].some((player) => player?.projectionSource === "weekly_projection"); } +function teamHasRosRankings(state: TeamManagerState): boolean { + return [ + ...state.roster.starters.map((slot) => slot.player), + ...state.roster.bench, + ...state.roster.injuredReserve, + ...state.roster.taxi, + ].some((player) => Boolean(player?.rosRank)); +} + function formatRosterCounts(state: TeamManagerState): string { return (["QB", "RB", "WR", "TE", "K", "DEF"] as const) .map((position) => `${position}:${state.roster.positionCounts[position] ?? 0}`) diff --git a/apps/api/src/ai/types.ts b/apps/api/src/ai/types.ts index feb6b0e..8ea3f78 100644 --- a/apps/api/src/ai/types.ts +++ b/apps/api/src/ai/types.ts @@ -29,6 +29,7 @@ export type DraftAiContext = { dataQuality: { playerValueSource: string; hasImportedRankings: boolean; + hasSeasonProjections: boolean; usesSleeperPlaceholderRanks: boolean; limitations: string[]; }; @@ -97,6 +98,9 @@ export type DraftAiContext = { reasons: string[]; source: string; importedRank?: number | null; + seasonProjectedPoints?: number | null; + sleeperAdp?: number | null; + realTimeAdp?: number | null; tier?: number | null; byeWeek?: number | null; riskTags: string[]; diff --git a/apps/api/src/data-management.ts b/apps/api/src/data-management.ts index 5dbc77a..67b2412 100644 --- a/apps/api/src/data-management.ts +++ b/apps/api/src/data-management.ts @@ -7,6 +7,9 @@ export type StorageInventory = { location: "application-data" | "development-data"; sqliteStorage: true; rankingImports: number; + seasonProjectionImports: number; + adpImports: number; + rosRankingImports: number; weeklyProjectionImports: number; decisionSnapshots: number; }; @@ -16,6 +19,9 @@ export function buildStorageInventory(database: SqliteAppDatabase): StorageInven location: process.env.SLEEPER_AI_DATA_DIR ? "application-data" : "development-data", sqliteStorage: true, rankingImports: database.countJson("ranking_imports"), + seasonProjectionImports: database.countJson("season_projection_imports"), + adpImports: database.countJson("adp_imports"), + rosRankingImports: database.countJson("ros_ranking_imports"), weeklyProjectionImports: database.countJson("weekly_projection_imports"), decisionSnapshots: database.countDecisionSnapshots(), }; diff --git a/apps/api/src/draft-value-import.test.ts b/apps/api/src/draft-value-import.test.ts new file mode 100644 index 0000000..5298098 --- /dev/null +++ b/apps/api/src/draft-value-import.test.ts @@ -0,0 +1,153 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { createMockDraftState } from "@sleeper-draft-assistant/engine"; +import type { DraftState, Player } from "@sleeper-draft-assistant/shared"; +import { describe, expect, it } from "vitest"; + +import { + AdpImportStore, + SeasonProjectionImportStore, + applyAdp, + applySeasonProjections, + importFantasyProsAdpCsv, + importFantasyProsSeasonProjectionCsvs, +} from "./draft-value-import"; +import { SqliteAppDatabase } from "./sqlite-app-database"; + +const qbCsv = `"Player","Team","ATT","CMP","YDS","TDS","INTS","ATT","YDS","TDS","FL","FPTS" +" ","","","" +"Josh Allen","BUF","500","330","4,000","30","10","100","500","10","2","365"`; + +const rbCsv = `"Player","Team","ATT","YDS","TDS","REC","YDS","TDS","FL","FPTS" +"Jahmyr Gibbs","DET","250","1,000","10","50","500","5","1","163"`; + +const kickerCsv = `"Player","Team","FG","FGA","XPT","FPTS" +"Brandon Aubrey","DAL","35","40","45","150"`; + +const adpCsv = `Rank,Player (Bye),POS,Sleeper,RTSports,AVG,Real-Time +1,Jahmyr Gibbs DET (6),RB1,2,-,2.0,1 +2,Josh Allen BUF (7),QB1,18,-,18.0,16`; + +describe("FantasyPros draft value imports", () => { + it("re-scores offensive season projections with Sleeper league scoring", () => { + const state = withPlayer( + withPprScoring(createMockDraftState(0)), + player("p-aubrey", "Brandon Aubrey", "DAL", "K"), + ); + const storedImport = importFantasyProsSeasonProjectionCsvs({ + state, + season: "2026", + files: [ + { position: "QB", csvText: qbCsv }, + { position: "RB", csvText: rbCsv }, + { position: "K", csvText: kickerCsv }, + ], + }); + const importedState = applySeasonProjections(state, storedImport); + const allen = importedState.players.find((player) => player.name === "Josh Allen"); + const gibbs = importedState.players.find((player) => player.name === "Jahmyr Gibbs"); + + expect(storedImport.summary.rowsParsed).toBe(3); + expect(storedImport.summary.matched).toBe(3); + expect(storedImport.summary.approximatePositions).toEqual(["K"]); + expect(allen).toMatchObject({ + projectedPoints: 376, + projectionSource: "season_projection", + seasonProjectionCoverage: "league_scored", + }); + expect(gibbs).toMatchObject({ + projectedPoints: 288, + projectionSource: "season_projection", + seasonProjectionCoverage: "league_scored", + }); + expect(importedState.players.find((player) => player.name === "Brandon Aubrey")).toMatchObject({ + projectedPoints: 150, + seasonProjectionCoverage: "provider_approximation", + }); + }); + + it("imports Sleeper and Real-Time ADP as market signals", () => { + const state = createMockDraftState(0); + const storedImport = importFantasyProsAdpCsv({ state, season: "2026", csvText: adpCsv }); + const importedState = applyAdp(state, storedImport); + + expect(storedImport.summary).toMatchObject({ + market: "Sleeper", + rowsParsed: 2, + matched: 2, + includesRealTime: true, + }); + expect(importedState.players.find((player) => player.name === "Jahmyr Gibbs")).toMatchObject({ + adp: 2, + realTimeAdp: 1, + adpSource: "FantasyPros Sleeper ADP", + }); + }); + + it("persists draft value imports in SQLite", async () => { + const state = withPprScoring(createMockDraftState(0)); + const dir = mkdtempSync(path.join(tmpdir(), "sleeper-ai-draft-values-")); + const database = await SqliteAppDatabase.open(path.join(dir, "app.sqlite")); + const projections = new SeasonProjectionImportStore(path.join(dir, "season.json"), database); + const adp = new AdpImportStore(path.join(dir, "adp.json"), database); + + projections.set(state.id, importFantasyProsSeasonProjectionCsvs({ + state, + season: "2026", + files: [{ position: "QB", csvText: qbCsv }], + })); + adp.set(state.id, importFantasyProsAdpCsv({ state, season: "2026", csvText: adpCsv })); + + const reopenedDatabase = await SqliteAppDatabase.open(path.join(dir, "app.sqlite")); + const reopenedProjections = new SeasonProjectionImportStore(path.join(dir, "season.json"), reopenedDatabase); + const reopenedAdp = new AdpImportStore(path.join(dir, "adp.json"), reopenedDatabase); + + expect(reopenedProjections.get(state.id)?.summary.matched).toBe(1); + expect(reopenedAdp.get(state.id)?.summary.matched).toBe(2); + }); +}); + +function withPprScoring(state: DraftState): DraftState { + return { + ...state, + settings: { + ...state.settings, + scoring: "PPR", + scoringSettings: { + pass_yd: 0.04, + pass_td: 4, + pass_int: -1, + rush_yd: 0.1, + rush_td: 6, + rec: 1, + rec_yd: 0.1, + rec_td: 6, + fum_lost: -2, + }, + }, + }; +} + +function withPlayer(state: DraftState, addedPlayer: Player): DraftState { + return { + ...state, + players: [...state.players, addedPlayer], + }; +} + +function player(id: string, name: string, team: string, position: Player["position"]): Player { + return { + id, + sleeperId: id, + name, + team, + position, + projectedPoints: 0, + projectionSource: "sleeper_search_rank", + adp: null, + tier: null, + riskTags: [], + }; +} diff --git a/apps/api/src/draft-value-import.ts b/apps/api/src/draft-value-import.ts new file mode 100644 index 0000000..34c64a8 --- /dev/null +++ b/apps/api/src/draft-value-import.ts @@ -0,0 +1,785 @@ +import { existsSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import type { + AdpImportSummary, + DraftState, + Player, + Position, + SeasonProjectionImportSummary, +} from "@sleeper-draft-assistant/shared"; + +import type { SqliteAppDatabase } from "./sqlite-app-database"; +import { readPrivateTextFile, removePrivateFile, writePrivateFile } from "./secure-file"; + +type MatchIssue = { + row: number; + name: string; + team: string | null; + position: Position | null; +}; + +type AmbiguousMatchIssue = MatchIssue & { + candidates: string[]; +}; + +type SeasonProjectionValue = { + points: number; + coverage: "league_scored" | "provider_approximation"; +}; + +type AdpValue = { + sleeperAdp: number; + realTimeAdp: number | null; +}; + +export type StoredSeasonProjectionImport = { + summary: SeasonProjectionImportSummary; + playersById: Map; +}; + +export type StoredAdpImport = { + summary: AdpImportSummary; + playersById: Map; +}; + +type SerializedSeasonProjectionImport = { + summary: SeasonProjectionImportSummary; + players: Array<[string, SeasonProjectionValue]>; +}; + +type SerializedAdpImport = { + summary: AdpImportSummary; + players: Array<[string, AdpValue]>; +}; + +type ProjectionRow = { + rowNumber: number; + name: string; + team: string | null; + position: Position; + stats: Record; + providerPoints: number | null; +}; + +type AdpRow = { + rowNumber: number; + name: string; + team: string | null; + position: Position | null; + sleeperAdp: number | null; + realTimeAdp: number | null; +}; + +const supportedPositions = new Set(["QB", "RB", "WR", "TE", "K", "DEF"]); +const approximateProjectionPositions = new Set(["K", "DEF"]); + +export class SeasonProjectionImportStore { + private readonly imports = new Map(); + + constructor( + private readonly filePath = getDefaultStorePath("season-projection-imports.json"), + private readonly database?: SqliteAppDatabase, + ) { + this.load(); + } + + set(draftId: string, storedImport: StoredSeasonProjectionImport) { + this.imports.set(draftId, storedImport); + this.saveDraft(draftId, storedImport); + } + + get(draftId: string): StoredSeasonProjectionImport | null { + return this.imports.get(draftId) ?? null; + } + + delete(draftId: string): boolean { + const deleted = this.imports.delete(draftId); + if (deleted) { + if (this.database) { + this.database.deleteJson("season_projection_imports", draftId); + } else { + this.saveFile(); + } + } + return deleted; + } + + clearAll(): number { + const deleted = this.imports.size; + this.imports.clear(); + if (this.database) { + this.database.clearJson("season_projection_imports"); + } else { + this.saveFile(); + } + removePrivateFile(this.filePath); + return deleted; + } + + apply(draftId: string, state: DraftState): DraftState { + const storedImport = this.get(draftId); + return storedImport ? applySeasonProjections(state, storedImport) : state; + } + + private load() { + if (this.database) { + for (const [draftId, storedImport] of this.database.listJson("season_projection_imports")) { + this.imports.set(draftId, deserializeSeasonProjectionImport(storedImport)); + } + if (this.imports.size > 0) { + return; + } + } + this.loadLegacyFile(); + } + + private loadLegacyFile() { + if (!existsSync(this.filePath)) { + return; + } + try { + const parsed = JSON.parse(readPrivateTextFile(this.filePath)) as Record; + for (const [draftId, storedImport] of Object.entries(parsed)) { + const deserialized = deserializeSeasonProjectionImport(storedImport); + this.imports.set(draftId, deserialized); + this.database?.setJson("season_projection_imports", draftId, serializeSeasonProjectionImport(deserialized)); + } + } catch { + this.imports.clear(); + } + } + + private saveDraft(draftId: string, storedImport: StoredSeasonProjectionImport) { + if (this.database) { + this.database.setJson("season_projection_imports", draftId, serializeSeasonProjectionImport(storedImport)); + } else { + this.saveFile(); + } + } + + private saveFile() { + writePrivateFile( + this.filePath, + `${JSON.stringify(Object.fromEntries( + Array.from(this.imports.entries()).map(([draftId, storedImport]) => [ + draftId, + serializeSeasonProjectionImport(storedImport), + ]), + ), null, 2)}\n`, + ); + } +} + +export class AdpImportStore { + private readonly imports = new Map(); + + constructor( + private readonly filePath = getDefaultStorePath("adp-imports.json"), + private readonly database?: SqliteAppDatabase, + ) { + this.load(); + } + + set(draftId: string, storedImport: StoredAdpImport) { + this.imports.set(draftId, storedImport); + this.saveDraft(draftId, storedImport); + } + + get(draftId: string): StoredAdpImport | null { + return this.imports.get(draftId) ?? null; + } + + delete(draftId: string): boolean { + const deleted = this.imports.delete(draftId); + if (deleted) { + if (this.database) { + this.database.deleteJson("adp_imports", draftId); + } else { + this.saveFile(); + } + } + return deleted; + } + + clearAll(): number { + const deleted = this.imports.size; + this.imports.clear(); + if (this.database) { + this.database.clearJson("adp_imports"); + } else { + this.saveFile(); + } + removePrivateFile(this.filePath); + return deleted; + } + + apply(draftId: string, state: DraftState): DraftState { + const storedImport = this.get(draftId); + return storedImport ? applyAdp(state, storedImport) : state; + } + + private load() { + if (this.database) { + for (const [draftId, storedImport] of this.database.listJson("adp_imports")) { + this.imports.set(draftId, deserializeAdpImport(storedImport)); + } + if (this.imports.size > 0) { + return; + } + } + this.loadLegacyFile(); + } + + private loadLegacyFile() { + if (!existsSync(this.filePath)) { + return; + } + try { + const parsed = JSON.parse(readPrivateTextFile(this.filePath)) as Record; + for (const [draftId, storedImport] of Object.entries(parsed)) { + const deserialized = deserializeAdpImport(storedImport); + this.imports.set(draftId, deserialized); + this.database?.setJson("adp_imports", draftId, serializeAdpImport(deserialized)); + } + } catch { + this.imports.clear(); + } + } + + private saveDraft(draftId: string, storedImport: StoredAdpImport) { + if (this.database) { + this.database.setJson("adp_imports", draftId, serializeAdpImport(storedImport)); + } else { + this.saveFile(); + } + } + + private saveFile() { + writePrivateFile( + this.filePath, + `${JSON.stringify(Object.fromEntries( + Array.from(this.imports.entries()).map(([draftId, storedImport]) => [ + draftId, + serializeAdpImport(storedImport), + ]), + ), null, 2)}\n`, + ); + } +} + +export function importFantasyProsSeasonProjectionCsvs(input: { + state: DraftState; + season: string; + files: Array<{ position: Position; csvText: string }>; +}): StoredSeasonProjectionImport { + const playersById = new Map(); + const unmatched: MatchIssue[] = []; + const ambiguous: AmbiguousMatchIssue[] = []; + const positions: Position[] = []; + let rowsParsed = 0; + + for (const file of input.files) { + if (!positions.includes(file.position)) { + positions.push(file.position); + } + const rows = parseProjectionRows(file.csvText, file.position); + rowsParsed += rows.length; + for (const row of rows) { + const match = matchPlayer(row, input.state.players); + if (match.kind === "matched") { + const coverage = approximateProjectionPositions.has(row.position) + ? "provider_approximation" + : "league_scored"; + const points = coverage === "league_scored" + ? scoreProjection(row, input.state) + : row.providerPoints; + if (points !== null) { + playersById.set(match.player.id, { points: round(points), coverage }); + } + } else if (match.kind === "ambiguous") { + ambiguous.push({ + row: row.rowNumber, + name: row.name, + team: row.team, + position: row.position, + candidates: match.players.map(formatPlayerCandidate), + }); + } else { + unmatched.push({ + row: row.rowNumber, + name: row.name, + team: row.team, + position: row.position, + }); + } + } + } + + const approximatePositions = positions.filter((position) => approximateProjectionPositions.has(position)); + return { + summary: { + source: "fantasypros", + season: input.season, + scoring: input.state.settings.scoring, + positions, + rowsParsed, + matched: playersById.size, + unmatched: unmatched.slice(0, 40), + ambiguous: ambiguous.slice(0, 40), + approximatePositions, + warnings: buildProjectionWarnings(input.state, approximatePositions), + appliedAt: new Date().toISOString(), + }, + playersById, + }; +} + +export function importFantasyProsAdpCsv(input: { + state: DraftState; + season: string; + csvText: string; +}): StoredAdpImport { + const rows = parseAdpRows(input.csvText); + const playersById = new Map(); + const unmatched: MatchIssue[] = []; + const ambiguous: AmbiguousMatchIssue[] = []; + + for (const row of rows) { + if (row.sleeperAdp === null || row.position === null) { + unmatched.push({ + row: row.rowNumber, + name: row.name, + team: row.team, + position: row.position, + }); + continue; + } + const match = matchPlayer(row, input.state.players); + if (match.kind === "matched") { + playersById.set(match.player.id, { + sleeperAdp: row.sleeperAdp, + realTimeAdp: row.realTimeAdp, + }); + } else if (match.kind === "ambiguous") { + ambiguous.push({ + row: row.rowNumber, + name: row.name, + team: row.team, + position: row.position, + candidates: match.players.map(formatPlayerCandidate), + }); + } else { + unmatched.push({ + row: row.rowNumber, + name: row.name, + team: row.team, + position: row.position, + }); + } + } + + return { + summary: { + source: "fantasypros", + market: "Sleeper", + season: input.season, + rowsParsed: rows.length, + matched: playersById.size, + unmatched: unmatched.slice(0, 40), + ambiguous: ambiguous.slice(0, 40), + includesRealTime: rows.some((row) => row.realTimeAdp !== null), + appliedAt: new Date().toISOString(), + }, + playersById, + }; +} + +export function applySeasonProjections(state: DraftState, storedImport: StoredSeasonProjectionImport): DraftState { + return { + ...state, + players: state.players.map((player) => applySeasonProjectionValue(player, storedImport)), + }; +} + +export function applySeasonProjectionValue( + player: Player, + storedImport: StoredSeasonProjectionImport | null, +): Player { + const value = storedImport?.playersById.get(player.id); + if (!value) { + return player; + } + return { + ...player, + projectedPoints: value.points, + projectionSource: "season_projection", + seasonProjectedPoints: value.points, + seasonProjectionSource: "FantasyPros", + seasonProjectionSeason: storedImport?.summary.season ?? null, + seasonProjectionCoverage: value.coverage, + }; +} + +export function applyAdp(state: DraftState, storedImport: StoredAdpImport): DraftState { + return { + ...state, + players: state.players.map((player) => applyAdpValue(player, storedImport)), + }; +} + +export function applyAdpValue(player: Player, storedImport: StoredAdpImport | null): Player { + const value = storedImport?.playersById.get(player.id); + if (!value) { + return player; + } + return { + ...player, + adp: value.sleeperAdp, + adpSource: "FantasyPros Sleeper ADP", + realTimeAdp: value.realTimeAdp, + }; +} + +function parseProjectionRows(csvText: string, position: Position): ProjectionRow[] { + const [headers, ...records] = parseCsv(csvText.trim()); + if (!headers) { + return []; + } + const headerIndexes = buildHeaderIndexes(headers); + return records + .map((record, index) => toProjectionRow(record, index + 2, position, headerIndexes)) + .filter((row): row is ProjectionRow => row !== null); +} + +function toProjectionRow( + record: string[], + rowNumber: number, + position: Position, + headerIndexes: Map, +): ProjectionRow | null { + const name = cleanText(getCell(record, headerIndexes, "player")); + if (!name) { + return null; + } + const team = nullableText(getCell(record, headerIndexes, "team")); + const providerPoints = numberFrom(getCell(record, headerIndexes, "fpts")); + const value = (header: string, occurrence = 0) => numberFrom(getCell(record, headerIndexes, header, occurrence)) ?? 0; + const stats: Record = {}; + + if (position === "QB") { + Object.assign(stats, { + pass_att: value("att", 0), + pass_cmp: value("cmp"), + pass_yd: value("yds", 0), + pass_td: value("tds", 0), + pass_int: value("ints"), + rush_att: value("att", 1), + rush_yd: value("yds", 1), + rush_td: value("tds", 1), + fum_lost: value("fl"), + }); + } else if (position === "RB" || position === "WR") { + Object.assign(stats, { + rush_att: value("att"), + rush_yd: value("yds", position === "RB" ? 0 : 1), + rush_td: value("tds", position === "RB" ? 0 : 1), + rec: value("rec"), + rec_yd: value("yds", position === "RB" ? 1 : 0), + rec_td: value("tds", position === "RB" ? 1 : 0), + fum_lost: value("fl"), + }); + } else if (position === "TE") { + Object.assign(stats, { + rec: value("rec"), + rec_yd: value("yds"), + rec_td: value("tds"), + fum_lost: value("fl"), + }); + } else if (position === "K") { + Object.assign(stats, { + fgm: value("fg"), + fga: value("fga"), + xpm: value("xpt"), + }); + } else { + Object.assign(stats, { + sack: value("sack"), + int: value("int"), + fum_rec: value("fr"), + ff: value("ff"), + def_td: value("td"), + safe: value("safety"), + points_allowed: value("pa"), + yards_allowed: value("ydsagn"), + }); + } + + return { rowNumber, name, team, position, stats, providerPoints }; +} + +function scoreProjection(row: ProjectionRow, state: DraftState): number { + const scoring = Object.keys(state.settings.scoringSettings ?? {}).length > 0 + ? state.settings.scoringSettings! + : defaultScoringSettings(state.settings.scoring); + return Object.entries(row.stats) + .reduce((total, [stat, value]) => total + value * (scoring[stat] ?? 0), 0); +} + +function defaultScoringSettings(scoringLabel: string): Record { + const receptionPoints = scoringLabel.toLowerCase().includes("half") + ? 0.5 + : scoringLabel.toLowerCase() === "ppr" + ? 1 + : 0; + return { + pass_yd: 0.04, + pass_td: 4, + pass_int: -1, + rush_yd: 0.1, + rush_td: 6, + rec: receptionPoints, + rec_yd: 0.1, + rec_td: 6, + fum_lost: -2, + }; +} + +function buildProjectionWarnings(state: DraftState, approximatePositions: Position[]): string[] { + const warnings: string[] = []; + if (approximatePositions.length > 0) { + warnings.push( + `${approximatePositions.join(" and ")} use FantasyPros provider points because their CSVs do not contain enough detail to reproduce every Sleeper scoring rule.`, + ); + } + const unsupportedScoringKeys = Object.entries(state.settings.scoringSettings ?? {}) + .filter(([key, value]) => value !== 0 && !supportedProjectionScoringKeys.has(key)) + .map(([key]) => key); + if (unsupportedScoringKeys.length > 0) { + warnings.push( + `Projection exports do not expose every stat needed for ${unsupportedScoringKeys.slice(0, 8).join(", ")}${unsupportedScoringKeys.length > 8 ? ", and other rules" : ""}.`, + ); + } + return warnings; +} + +const supportedProjectionScoringKeys = new Set([ + "pass_att", + "pass_cmp", + "pass_yd", + "pass_td", + "pass_int", + "rush_att", + "rush_yd", + "rush_td", + "rec", + "rec_yd", + "rec_td", + "fum_lost", +]); + +function parseAdpRows(csvText: string): AdpRow[] { + const [headers, ...records] = parseCsv(csvText.trim()); + if (!headers) { + return []; + } + const headerIndexes = buildHeaderIndexes(headers); + return records + .map((record, index) => { + const playerText = cleanText(getCell(record, headerIndexes, "playerbye")); + const positionText = cleanText(getCell(record, headerIndexes, "pos")); + const player = parseAdpPlayer(playerText); + return { + rowNumber: index + 2, + name: player.name, + team: player.team, + position: normalizePosition(positionText.replace(/\d+$/, "")), + sleeperAdp: numberFrom(getCell(record, headerIndexes, "sleeper")), + realTimeAdp: numberFrom(getCell(record, headerIndexes, "realtime")), + }; + }) + .filter((row) => row.name.length > 0); +} + +function parseAdpPlayer(value: string): { name: string; team: string | null } { + const match = value.match(/^(.*?)\s+([A-Z]{2,3}|FA)\s+\([^)]*\)$/); + return match + ? { name: match[1]!.trim(), team: nullableText(match[2] ?? "") } + : { name: value.trim(), team: null }; +} + +function matchPlayer( + row: { name: string; team: string | null; position: Position | null }, + players: Player[], +) { + const normalizedName = normalizeName(row.name); + const candidates = players.filter((player) => { + if (row.position && player.position !== row.position) { + return false; + } + return normalizeName(player.name) === normalizedName; + }); + const teamMatches = row.team + ? candidates.filter((player) => normalizeTeam(player.team) === normalizeTeam(row.team!)) + : []; + const matches = teamMatches.length > 0 ? teamMatches : candidates; + if (matches.length === 1) { + return { kind: "matched" as const, player: matches[0]! }; + } + if (matches.length > 1) { + return { kind: "ambiguous" as const, players: matches }; + } + return { kind: "unmatched" as const }; +} + +function buildHeaderIndexes(headers: string[]): Map { + const indexes = new Map(); + headers.forEach((header, index) => { + const key = normalizeHeader(header); + indexes.set(key, [...(indexes.get(key) ?? []), index]); + }); + return indexes; +} + +function getCell( + record: string[], + indexes: Map, + header: string, + occurrence = 0, +): string { + const index = indexes.get(header)?.[occurrence]; + return index === undefined ? "" : (record[index] ?? "").trim(); +} + +function parseCsv(text: string): string[][] { + const rows: string[][] = []; + let row: string[] = []; + let cell = ""; + let inQuotes = false; + for (let index = 0; index < text.length; index += 1) { + const char = text[index]; + const next = text[index + 1]; + if (char === '"') { + if (inQuotes && next === '"') { + cell += '"'; + index += 1; + } else { + inQuotes = !inQuotes; + } + } else if (char === "," && !inQuotes) { + row.push(cell); + cell = ""; + } else if ((char === "\n" || char === "\r") && !inQuotes) { + if (char === "\r" && next === "\n") { + index += 1; + } + row.push(cell); + if (row.some((value) => value.trim().length > 0)) { + rows.push(row); + } + row = []; + cell = ""; + } else { + cell += char; + } + } + row.push(cell); + if (row.some((value) => value.trim().length > 0)) { + rows.push(row); + } + return rows; +} + +function serializeSeasonProjectionImport( + storedImport: StoredSeasonProjectionImport, +): SerializedSeasonProjectionImport { + return { + summary: storedImport.summary, + players: Array.from(storedImport.playersById.entries()), + }; +} + +function deserializeSeasonProjectionImport( + storedImport: SerializedSeasonProjectionImport, +): StoredSeasonProjectionImport { + return { + summary: storedImport.summary, + playersById: new Map(storedImport.players), + }; +} + +function serializeAdpImport(storedImport: StoredAdpImport): SerializedAdpImport { + return { + summary: storedImport.summary, + players: Array.from(storedImport.playersById.entries()), + }; +} + +function deserializeAdpImport(storedImport: SerializedAdpImport): StoredAdpImport { + return { + summary: storedImport.summary, + playersById: new Map(storedImport.players), + }; +} + +function normalizeHeader(header: string): string { + return cleanText(header).toLowerCase().replace(/[^a-z0-9]/g, ""); +} + +function normalizeName(name: string): string { + const normalized = cleanText(name) + .toLowerCase() + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/\b(jr|sr|ii|iii|iv|v)\b/g, "") + .replace(/[^a-z0-9]/g, ""); + return playerNameAliases[normalized] ?? normalized; +} + +const playerNameAliases: Record = { + bamknight: "zonovanknight", + hollywoodbrown: "marquisebrown", +}; + +function normalizeTeam(team: string): string { + return team.toUpperCase() === "JAC" ? "JAX" : team.toUpperCase(); +} + +function normalizePosition(value: string): Position | null { + const normalized = value.toUpperCase() === "DST" ? "DEF" : value.toUpperCase(); + return supportedPositions.has(normalized as Position) ? (normalized as Position) : null; +} + +function cleanText(value: string): string { + return value.replace(/\u00c2/g, "").replace(/\u00a0/g, " ").trim(); +} + +function nullableText(value: string): string | null { + const cleaned = cleanText(value); + return cleaned ? cleaned : null; +} + +function numberFrom(value: string | undefined): number | null { + const cleaned = cleanText(value ?? "").replace(/,/g, "").trim(); + if (!/^[+-]?\d+(?:\.\d+)?$/.test(cleaned)) { + return null; + } + const parsed = Number(cleaned); + return Number.isFinite(parsed) ? parsed : null; +} + +function round(value: number): number { + return Number(value.toFixed(1)); +} + +function formatPlayerCandidate(player: Player): string { + return `${player.name} (${player.team} ${player.position})`; +} + +function getDefaultStorePath(fileName: string): string { + const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); + return process.env.SLEEPER_AI_DATA_DIR + ? path.join(process.env.SLEEPER_AI_DATA_DIR, fileName) + : path.join(repoRoot, "data", fileName); +} diff --git a/apps/api/src/format-compatibility.test.ts b/apps/api/src/format-compatibility.test.ts new file mode 100644 index 0000000..0e1a293 --- /dev/null +++ b/apps/api/src/format-compatibility.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; + +import { assessFormatCompatibility } from "./format-compatibility"; + +const baseSlots = { QB: 1, RB: 2, WR: 2, TE: 1, FLEX: 1, BN: 6 }; + +describe("league format compatibility", () => { + it.each(["PPR", "Half PPR", "Standard"])("supports conventional %s redraft leagues", (scoring) => { + expect(assessFormatCompatibility({ scoring, rosterSlots: baseSlots })).toEqual({ + level: "supported", + features: [], + warnings: [], + }); + }); + + it("recognizes superflex without lowering support", () => { + const result = assessFormatCompatibility({ + scoring: "Half PPR", + rosterSlots: { ...baseSlots, SUPER_FLEX: 1 }, + draftType: "snake", + }); + + expect(result.level).toBe("supported"); + expect(result.features).toContain("superflex"); + }); + + it("warns for custom and TE-premium scoring", () => { + const result = assessFormatCompatibility({ + scoring: "Custom", + scoringSettings: { rec: 0.75, bonus_rec_te: 0.5 }, + rosterSlots: baseSlots, + }); + + expect(result.level).toBe("caution"); + expect(result.features).toEqual(expect.arrayContaining(["custom_scoring", "te_premium"])); + expect(result.warnings.join(" ")).toContain("TE-premium"); + }); + + it.each([ + [{ ...baseSlots, LB: 2, DB: 2 }, "snake", "idp"], + [baseSlots, "auction", "auction"], + ] as const)("marks unsupported formats", (rosterSlots, draftType, feature) => { + const result = assessFormatCompatibility({ scoring: "PPR", rosterSlots, draftType }); + + expect(result.level).toBe("unsupported"); + expect(result.features).toContain(feature); + }); +}); diff --git a/apps/api/src/format-compatibility.ts b/apps/api/src/format-compatibility.ts new file mode 100644 index 0000000..2b88102 --- /dev/null +++ b/apps/api/src/format-compatibility.ts @@ -0,0 +1,67 @@ +import type { FormatCompatibility } from "@sleeper-draft-assistant/shared"; + +const supportedRosterSlots = new Set([ + "QB", "RB", "WR", "TE", "K", "DEF", + "FLEX", "WR_RB_FLEX", "REC_FLEX", "SUPER_FLEX", "SF", + "BN", "IR", "TAXI", +]); +const idpRosterSlots = new Set(["DL", "DE", "DT", "LB", "DB", "CB", "S", "IDP", "IDP_FLEX"]); + +export function assessFormatCompatibility(input: { + scoring: string; + scoringSettings?: Record; + rosterSlots: Record; + draftType?: string | null; +}): FormatCompatibility { + const features: FormatCompatibility["features"] = []; + const warnings: string[] = []; + const activeSlots = Object.entries(input.rosterSlots) + .filter(([, count]) => count > 0) + .map(([slot]) => slot.toUpperCase()); + const draftType = input.draftType?.trim().toLowerCase() ?? ""; + const hasIdp = activeSlots.some((slot) => idpRosterSlots.has(slot)); + const unknownSlots = activeSlots.filter((slot) => !supportedRosterSlots.has(slot) && !idpRosterSlots.has(slot)); + const hasSuperflex = activeSlots.some((slot) => slot === "SUPER_FLEX" || slot === "SF"); + const hasTePremium = isTePremium(input.scoringSettings); + const hasCustomScoring = input.scoring.trim().toLowerCase() === "custom"; + const hasAuction = draftType === "auction"; + + if (hasSuperflex) { + features.push("superflex"); + } + if (hasTePremium) { + features.push("te_premium"); + warnings.push("TE-premium roster demand is recognized, but imported ECR, ROS ranks, and weekly FPTS must come from a matching TE-premium source."); + } + if (hasCustomScoring) { + features.push("custom_scoring"); + warnings.push("Custom Sleeper scoring is only partially modeled; imported ranks and provider-scored weekly points may not reflect every rule."); + } + if (hasIdp) { + features.push("idp"); + warnings.push("IDP positions are not supported and are excluded from player values, lineup optimization, and recommendations."); + } + if (hasAuction) { + features.push("auction"); + warnings.push("Auction and salary values are not supported; recommendations do not account for budgets or nomination strategy."); + } + if (unknownSlots.length > 0) { + warnings.push(`Unrecognized roster slots are not modeled: ${unknownSlots.join(", ")}.`); + } + + return { + level: hasIdp || hasAuction + ? "unsupported" + : warnings.length > 0 + ? "caution" + : "supported", + features, + warnings, + }; +} + +function isTePremium(scoringSettings: Record | undefined): boolean { + return Object.entries(scoringSettings ?? {}).some(([key, value]) => + value !== 0 && ["bonus_rec_te", "rec_te"].includes(key.toLowerCase()), + ); +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 2a79844..0bd425a 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -13,14 +13,16 @@ import { Hono } from "hono"; import type { Context } from "hono"; import { cors } from "hono/cors"; -import { RankingImportRequestSchema, WeeklyProjectionBatchImportRequestSchema, WeeklyProjectionImportRequestSchema, type AppSettings, type DraftRecommendation, type DraftState, type RankingImportSummary, type Player, type TeamActivitySummary, type TeamDataReadiness, type TeamLineupSummary, type TeamManagerState, type TeamNeedsSummary, type TeamWaiverSummary, type TeamWeekContext, type WeeklyProjectionImportSummary } from "@sleeper-draft-assistant/shared"; +import { AdpImportRequestSchema, RankingImportRequestSchema, RosRankingImportRequestSchema, SeasonProjectionImportRequestSchema, WeeklyProjectionBatchImportRequestSchema, WeeklyProjectionImportRequestSchema, type AdpImportSummary, type AppSettings, type DraftRecommendation, type DraftState, type RankingImportSummary, type Player, type RosRankingImportSummary, type SeasonProjectionImportSummary, type TeamActivitySummary, type TeamDataReadiness, type TeamLineupSummary, type TeamManagerState, type TeamNeedsSummary, type TeamWaiverSummary, type TeamWeekContext, type WeeklyProjectionImportSummary } from "@sleeper-draft-assistant/shared"; import { buildDraftAiContext } from "./ai/context"; import { buildTeamAiContext } from "./ai/team-context"; import { DecisionLogStore, type DecisionSnapshotTrigger } from "./decision-log-store"; import { createEventStreamChannel } from "./event-stream"; import { createAiProvider } from "./ai/provider-factory"; -import { applyImportedPlayerValues, importFantasyProsCsv, RankingImportStore } from "./rankings-import"; +import { applyImportedPlayerValues, importFantasyProsCsv, isDraftRankingImportCompatible, RankingImportStore } from "./rankings-import"; +import { AdpImportStore, SeasonProjectionImportStore, applyAdpValue, applySeasonProjectionValue, importFantasyProsAdpCsv, importFantasyProsSeasonProjectionCsvs } from "./draft-value-import"; +import { RosRankingImportStore, applyRosRankingsToPlayers, applyRosRankingsToTeamState, importFantasyProsRosRankings, isRosRankingImportActive, isRosScoringCompatible, normalizeScoringFormat } from "./ros-rankings-import"; import { WeeklyProjectionImportStore, applyWeeklyProjectionsToPlayers, applyWeeklyProjectionsToTeamState, importFantasyProsWeeklyProjectionCsv, isWeeklyProjectionImportActive, mergeWeeklyProjectionImports } from "./weekly-projections-import"; import { getSleeperConnectOptions } from "./sleeper-connect"; import { SleeperApiError, SleeperClient } from "./sleeper"; @@ -37,6 +39,9 @@ const apiToken = process.env.SLEEPER_AI_API_TOKEN?.trim() || null; const sleeperClient = new SleeperClient(); const appDatabase = await SqliteAppDatabase.open(); const rankingImportStore = new RankingImportStore(undefined, appDatabase); +const seasonProjectionImportStore = new SeasonProjectionImportStore(undefined, appDatabase); +const adpImportStore = new AdpImportStore(undefined, appDatabase); +const rosRankingImportStore = new RosRankingImportStore(undefined, appDatabase); const weeklyProjectionImportStore = new WeeklyProjectionImportStore(undefined, appDatabase); const decisionLogStore = new DecisionLogStore(undefined, 200, appDatabase); const settingsStore = new SettingsStore(undefined, appDatabase); @@ -46,6 +51,8 @@ type DraftPayload = { state: DraftState; recommendation: DraftRecommendation; rankingImportSummary: RankingImportSummary | null; + seasonProjectionImportSummary: SeasonProjectionImportSummary | null; + adpImportSummary: AdpImportSummary | null; }; type DraftPayloadOptions = { @@ -62,6 +69,7 @@ type TeamPayload = { weekContext: TeamWeekContext | null; waiverSummary: TeamWaiverSummary; activitySummary: TeamActivitySummary; + rosRankingSummary: RosRankingImportSummary | null; weeklyProjectionSummary: WeeklyProjectionImportSummary | null; }; @@ -108,6 +116,12 @@ app.delete("/data/:category", async (c) => { let deleted = 0; if (category === "rankings") { deleted = rankingImportStore.clearAll(); + } else if (category === "season-projections") { + deleted = seasonProjectionImportStore.clearAll(); + } else if (category === "adp") { + deleted = adpImportStore.clearAll(); + } else if (category === "ros-rankings") { + deleted = rosRankingImportStore.clearAll(); } else if (category === "weekly-projections") { deleted = weeklyProjectionImportStore.clearAll(); } else if (category === "decision-history") { @@ -129,6 +143,9 @@ app.post("/data/reset", async (c) => { } rankingImportStore.clearAll(); + seasonProjectionImportStore.clearAll(); + adpImportStore.clearAll(); + rosRankingImportStore.clearAll(); weeklyProjectionImportStore.clearAll(); decisionLogStore.clearAll(); const settings = settingsStore.reset(); @@ -152,7 +169,7 @@ app.put("/settings", async (c) => { app.get("/ai/status", (c) => c.json(createAiProvider(settingsStore.get()).status())); app.get("/drafts/mock/state", (c) => { - return c.json(toDraftPayload(rankingImportStore.apply("mock-draft", mockState), "mock-draft")); + return c.json(toDraftPayload(applyDraftData("mock-draft", mockState), "mock-draft")); }); app.get("/sleeper/connect", async (c) => { @@ -184,10 +201,21 @@ app.get("/leagues/:leagueId/team", async (c) => { const selectedWeek = getWeek(c) ?? state.week ?? 1; const weeklyImport = getWeeklyProjectionImport(c, leagueId, state.league.season, selectedWeek); const activeWeeklyImport = isWeeklyProjectionImportActive(state, weeklyImport, selectedWeek) ? weeklyImport : null; - const projectedState = applyWeeklyProjectionsToTeamState(state, activeWeeklyImport); + const rosImport = getRosRankingImport(c, leagueId, state.league.season, state.league.scoring); + const activeRosImport = isRosRankingImportActive(state, rosImport) ? rosImport : null; + const rankedState = applyRosRankingsToTeamState(state, activeRosImport); + const projectedState = applyWeeklyProjectionsToTeamState(rankedState, activeWeeklyImport); const availableWithRanks = applyTeamRankingImport(c, availablePlayers); - const projectedAvailablePlayers = applyWeeklyProjectionsToPlayers(availableWithRanks, activeWeeklyImport); - return c.json(toTeamPayload(projectedState, weekContext, projectedAvailablePlayers, activitySummary, weeklyImport?.summary ?? null)); + const availableWithRos = applyRosRankingsToPlayers(availableWithRanks, activeRosImport); + const projectedAvailablePlayers = applyWeeklyProjectionsToPlayers(availableWithRos, activeWeeklyImport); + return c.json(toTeamPayload( + projectedState, + weekContext, + projectedAvailablePlayers, + activitySummary, + weeklyImport?.summary ?? null, + rosImport?.summary ?? null, + )); } catch (error) { return handleRouteError(c, error); } @@ -214,9 +242,13 @@ app.post("/leagues/:leagueId/team/ask", async (c) => { const selectedWeek = getWeek(c) ?? state.week ?? 1; const weeklyImport = getWeeklyProjectionImport(c, leagueId, state.league.season, selectedWeek); const activeWeeklyImport = isWeeklyProjectionImportActive(state, weeklyImport, selectedWeek) ? weeklyImport : null; - const projectedState = applyWeeklyProjectionsToTeamState(state, activeWeeklyImport); + const rosImport = getRosRankingImport(c, leagueId, state.league.season, state.league.scoring); + const activeRosImport = isRosRankingImportActive(state, rosImport) ? rosImport : null; + const rankedState = applyRosRankingsToTeamState(state, activeRosImport); + const projectedState = applyWeeklyProjectionsToTeamState(rankedState, activeWeeklyImport); const rankedAvailablePlayers = applyTeamRankingImport(c, availablePlayers); - const projectedAvailablePlayers = applyWeeklyProjectionsToPlayers(rankedAvailablePlayers, activeWeeklyImport); + const availableWithRos = applyRosRankingsToPlayers(rankedAvailablePlayers, activeRosImport); + const projectedAvailablePlayers = applyWeeklyProjectionsToPlayers(availableWithRos, activeWeeklyImport); const aiProvider = createAiProvider(settingsStore.get()); const waiverSummary = buildTeamWaiverSummary(projectedState, projectedAvailablePlayers); const lineupSummary = buildTeamLineupSummary(projectedState); @@ -229,7 +261,109 @@ app.post("/leagues/:leagueId/team/ask", async (c) => { provider: aiAnswer.provider, question, answer: aiAnswer.answer, - ...toTeamPayload(projectedState, weekContext, projectedAvailablePlayers, activitySummary, weeklyImport?.summary ?? null), + ...toTeamPayload( + projectedState, + weekContext, + projectedAvailablePlayers, + activitySummary, + weeklyImport?.summary ?? null, + rosImport?.summary ?? null, + ), + }); + } catch (error) { + return handleRouteError(c, error); + } +}); + +app.get("/leagues/:leagueId/rankings/ros", async (c) => { + try { + const leagueId = c.req.param("leagueId"); + const season = c.req.query("season")?.trim(); + const scoring = c.req.query("scoring")?.trim(); + if (!season || !scoring) { + return c.json({ summary: null }); + } + const storedImport = rosRankingImportStore.get({ + leagueId, + season, + scoring: normalizeScoringFormat(scoring), + }); + return c.json({ summary: storedImport?.summary ?? null }); + } catch (error) { + return handleRouteError(c, error); + } +}); + +app.post("/leagues/:leagueId/rankings/ros/import", async (c) => { + try { + const leagueId = c.req.param("leagueId"); + const userRosterId = getUserRosterId(c); + const body = RosRankingImportRequestSchema.parse(await c.req.json()); + const [state, weekContext, availablePlayers, activitySummary, importPlayers] = await Promise.all([ + sleeperClient.getTeamManagerState(leagueId, userRosterId), + sleeperClient.getTeamWeekContext(leagueId, getWeek(c), userRosterId).catch(() => null), + sleeperClient.getAvailablePlayers(leagueId).catch(() => []), + sleeperClient.getTeamActivitySummary(leagueId, getWeek(c)).catch(() => null), + sleeperClient.getProjectionImportPlayers(), + ]); + if (!isRosScoringCompatible(body.scoring, state.league.scoring)) { + return c.json({ + error: `The ${body.scoring} ROS rankings do not match this ${state.league.scoring} league.`, + }, 400); + } + const playerPool = uniquePlayers([...getTeamRosterPlayers(state), ...importPlayers]); + const storedImport = importFantasyProsRosRankings({ + players: playerPool, + season: body.season, + scoring: body.scoring, + csvText: body.csvText, + }); + rosRankingImportStore.set({ + leagueId, + season: body.season, + scoring: body.scoring, + }, storedImport); + + const activeRosImport = isRosRankingImportActive(state, storedImport) ? storedImport : null; + const rankedState = applyRosRankingsToTeamState(state, activeRosImport); + const selectedWeek = getWeek(c) ?? state.week ?? 1; + const weeklyImport = getWeeklyProjectionImport(c, leagueId, state.league.season, selectedWeek); + const activeWeeklyImport = isWeeklyProjectionImportActive(state, weeklyImport, selectedWeek) ? weeklyImport : null; + const projectedState = applyWeeklyProjectionsToTeamState(rankedState, activeWeeklyImport); + const availableWithDraftValues = applyTeamRankingImport(c, availablePlayers); + const availableWithRos = applyRosRankingsToPlayers(availableWithDraftValues, activeRosImport); + const projectedAvailablePlayers = applyWeeklyProjectionsToPlayers(availableWithRos, activeWeeklyImport); + + return c.json({ + summary: storedImport.summary, + ...toTeamPayload( + projectedState, + weekContext, + projectedAvailablePlayers, + activitySummary, + weeklyImport?.summary ?? null, + storedImport.summary, + ), + }); + } catch (error) { + return handleRouteError(c, error); + } +}); + +app.delete("/leagues/:leagueId/rankings/ros", async (c) => { + try { + const leagueId = c.req.param("leagueId"); + const season = c.req.query("season")?.trim(); + const scoring = c.req.query("scoring")?.trim(); + if (!season || !scoring) { + return c.json({ deleted: false }); + } + return c.json({ + deleted: rosRankingImportStore.delete({ + leagueId, + season, + scoring: normalizeScoringFormat(scoring), + }), }); } catch (error) { return handleRouteError(c, error); @@ -286,13 +420,24 @@ app.post("/leagues/:leagueId/projections/weekly/import", async (c) => { } weeklyProjectionImportStore.set({ leagueId, season, week }, storedImport!); const activeWeeklyImport = isWeeklyProjectionImportActive(state, storedImport, week) ? storedImport : null; - const projectedState = applyWeeklyProjectionsToTeamState(state, activeWeeklyImport); + const rosImport = getRosRankingImport(c, leagueId, state.league.season, state.league.scoring); + const activeRosImport = isRosRankingImportActive(state, rosImport) ? rosImport : null; + const rankedState = applyRosRankingsToTeamState(state, activeRosImport); + const projectedState = applyWeeklyProjectionsToTeamState(rankedState, activeWeeklyImport); const rankedAvailablePlayers = applyTeamRankingImport(c, availablePlayers); - const projectedAvailablePlayers = applyWeeklyProjectionsToPlayers(rankedAvailablePlayers, activeWeeklyImport); + const availableWithRos = applyRosRankingsToPlayers(rankedAvailablePlayers, activeRosImport); + const projectedAvailablePlayers = applyWeeklyProjectionsToPlayers(availableWithRos, activeWeeklyImport); return c.json({ summary: storedImport!.summary, - ...toTeamPayload(projectedState, weekContext, projectedAvailablePlayers, activitySummary, storedImport!.summary), + ...toTeamPayload( + projectedState, + weekContext, + projectedAvailablePlayers, + activitySummary, + storedImport!.summary, + rosImport?.summary ?? null, + ), }); } catch (error) { return handleRouteError(c, error); @@ -328,9 +473,14 @@ app.post("/drafts/:draftId/rankings/import", async (c) => { const draftId = c.req.param("draftId"); const state = await loadDraftState(draftId, getUserRosterId(c)); const body = RankingImportRequestSchema.parse(await c.req.json()); - const storedImport = importFantasyProsCsv(state, body.csvText); + const storedImport = importFantasyProsCsv(state, body.csvText, body.scoring); + if (!isDraftRankingImportCompatible(state, storedImport)) { + return c.json({ + error: `The ${body.scoring} rankings do not match this ${state.settings.scoring} league.`, + }, 400); + } rankingImportStore.set(draftId, storedImport); - const importedState = rankingImportStore.apply(draftId, state); + const importedState = applyDraftData(draftId, state); return c.json({ summary: storedImport.summary, @@ -341,6 +491,80 @@ app.post("/drafts/:draftId/rankings/import", async (c) => { } }); +app.post("/drafts/:draftId/projections/season/import", async (c) => { + try { + const draftId = c.req.param("draftId"); + const state = await loadDraftState(draftId, getUserRosterId(c)); + const body = SeasonProjectionImportRequestSchema.parse(await c.req.json()); + const storedImport = importFantasyProsSeasonProjectionCsvs({ + state, + season: body.season, + files: body.files, + }); + seasonProjectionImportStore.set(draftId, storedImport); + return c.json({ + summary: storedImport.summary, + ...toDraftPayload(applyDraftData(draftId, state), draftId, { + recordTrigger: "rankings-import", + userRosterId: getUserRosterId(c), + }), + }); + } catch (error) { + return handleRouteError(c, error); + } +}); + +app.delete("/drafts/:draftId/projections/season/import", async (c) => { + try { + const draftId = c.req.param("draftId"); + seasonProjectionImportStore.delete(draftId); + const state = await loadDraftState(draftId, getUserRosterId(c)); + return c.json(toDraftPayload(state, draftId, { + recordTrigger: "rankings-clear", + userRosterId: getUserRosterId(c), + })); + } catch (error) { + return handleRouteError(c, error); + } +}); + +app.post("/drafts/:draftId/adp/import", async (c) => { + try { + const draftId = c.req.param("draftId"); + const state = await loadDraftState(draftId, getUserRosterId(c)); + const body = AdpImportRequestSchema.parse(await c.req.json()); + const storedImport = importFantasyProsAdpCsv({ + state, + season: body.season, + csvText: body.csvText, + }); + adpImportStore.set(draftId, storedImport); + return c.json({ + summary: storedImport.summary, + ...toDraftPayload(applyDraftData(draftId, state), draftId, { + recordTrigger: "rankings-import", + userRosterId: getUserRosterId(c), + }), + }); + } catch (error) { + return handleRouteError(c, error); + } +}); + +app.delete("/drafts/:draftId/adp/import", async (c) => { + try { + const draftId = c.req.param("draftId"); + adpImportStore.delete(draftId); + const state = await loadDraftState(draftId, getUserRosterId(c)); + return c.json(toDraftPayload(state, draftId, { + recordTrigger: "rankings-clear", + userRosterId: getUserRosterId(c), + })); + } catch (error) { + return handleRouteError(c, error); + } +}); + app.delete("/drafts/:draftId/rankings/import", async (c) => { try { const draftId = c.req.param("draftId"); @@ -427,11 +651,18 @@ app.get("/drafts/:draftId/events", async (c) => { async function loadDraftState(draftId: string, userRosterId?: string | null): Promise { if (isMockDraft(draftId)) { - return rankingImportStore.apply(draftId, mockState); + return applyDraftData(draftId, mockState); } const state = await sleeperClient.getDraftState(draftId, userRosterId); - return rankingImportStore.apply(draftId, state); + return applyDraftData(draftId, state); +} + +function applyDraftData(draftId: string, state: DraftState): DraftState { + return adpImportStore.apply( + draftId, + seasonProjectionImportStore.apply(draftId, rankingImportStore.apply(draftId, state)), + ); } function toTeamPayload( @@ -440,6 +671,7 @@ function toTeamPayload( availablePlayers: Player[] = [], activitySummary: TeamActivitySummary | null = null, weeklyProjectionSummary: WeeklyProjectionImportSummary | null = null, + rosRankingSummary: RosRankingImportSummary | null = null, ): TeamPayload { return { state, @@ -449,6 +681,7 @@ function toTeamPayload( weekContext, waiverSummary: buildTeamWaiverSummary(state, availablePlayers), activitySummary: activitySummary ?? emptyActivitySummary(), + rosRankingSummary, weeklyProjectionSummary, }; } @@ -481,6 +714,8 @@ function toDraftPayload(state: DraftState, draftId: string, options: DraftPayloa state, recommendation, rankingImportSummary: rankingImportStore.get(draftId)?.summary ?? null, + seasonProjectionImportSummary: seasonProjectionImportStore.get(draftId)?.summary ?? null, + adpImportSummary: adpImportStore.get(draftId)?.summary ?? null, }; } @@ -506,6 +741,9 @@ function createDiagnosticsPayload() { sqliteStorage: true, settingsRecords: appDatabase.countJson("settings"), rankingImportRecords: appDatabase.countJson("ranking_imports"), + seasonProjectionImportRecords: appDatabase.countJson("season_projection_imports"), + adpImportRecords: appDatabase.countJson("adp_imports"), + rosRankingImportRecords: appDatabase.countJson("ros_ranking_imports"), weeklyProjectionImportRecords: appDatabase.countJson("weekly_projection_imports"), decisionSnapshots: appDatabase.countDecisionSnapshots(), }, @@ -565,6 +803,24 @@ function getWeeklyProjectionImport(c: Context, leagueId: string, fallbackSeason: return weeklyProjectionImportStore.get({ leagueId, season, week }); } +function getRosRankingImport( + c: Context, + leagueId: string, + fallbackSeason: string | null, + fallbackScoring: string, +) { + const season = c.req.query("season")?.trim() || fallbackSeason; + const scoring = c.req.query("scoring")?.trim() || fallbackScoring; + if (!season) { + return null; + } + return rosRankingImportStore.get({ + leagueId, + season, + scoring: normalizeScoringFormat(scoring), + }); +} + function getTeamRosterPlayers(state: TeamManagerState): Player[] { return uniquePlayers([ ...state.roster.starters.map((slot) => slot.player).filter(isPlayer), @@ -584,8 +840,13 @@ function isPlayer(player: Player | null): player is Player { function applyTeamRankingImport(c: Context, players: Player[]) { const draftId = c.req.query("draftId") ?? null; - const storedImport = draftId ? rankingImportStore.get(draftId) : null; - return players.map((player) => applyImportedPlayerValues(player, storedImport)); + const rankingImport = draftId ? rankingImportStore.get(draftId) : null; + const seasonProjectionImport = draftId ? seasonProjectionImportStore.get(draftId) : null; + const adpImport = draftId ? adpImportStore.get(draftId) : null; + return players.map((player) => applyAdpValue( + applySeasonProjectionValue(applyImportedPlayerValues(player, rankingImport), seasonProjectionImport), + adpImport, + )); } function getWeek(c: Context): number | null { @@ -632,7 +893,7 @@ export function redactErrorMessage(message: string): string { function streamMockDraftEvents(): Response { const channel = createEventStreamChannel(); - let localState: DraftState = rankingImportStore.apply("mock-draft", mockState); + let localState: DraftState = applyDraftData("mock-draft", mockState); let interval: ReturnType | undefined; const stream = new ReadableStream({ @@ -653,7 +914,7 @@ function streamMockDraftEvents(): Response { const previousPickCount = mockState.picks.length; mockState = advanceMockDraftState(mockState); - localState = rankingImportStore.apply("mock-draft", mockState); + localState = applyDraftData("mock-draft", mockState); const pick = localState.picks[previousPickCount]; channel.send(controller, "pick", { diff --git a/apps/api/src/rankings-import.test.ts b/apps/api/src/rankings-import.test.ts index 2408164..3ea1d05 100644 --- a/apps/api/src/rankings-import.test.ts +++ b/apps/api/src/rankings-import.test.ts @@ -6,7 +6,7 @@ import { buildDraftRecommendation, createMockDraftState } from "@sleeper-draft-a import type { DraftState, Player } from "@sleeper-draft-assistant/shared"; import { describe, expect, it } from "vitest"; -import { RankingImportStore, applyImportedRankings, importFantasyProsCsv } from "./rankings-import"; +import { RankingImportStore, applyImportedRankings, importFantasyProsCsv, isDraftRankingImportCompatible } from "./rankings-import"; import { SqliteAppDatabase } from "./sqlite-app-database"; const csv = `"RK",TIERS,"PLAYER NAME",TEAM,"POS","BYE WEEK","UPSIDE ","BUST ","SOS SEASON","ECR VS. ADP" @@ -15,6 +15,14 @@ const csv = `"RK",TIERS,"PLAYER NAME",TEAM,"POS","BYE WEEK","UPSIDE ","BUST ","S "3",1,"Unknown Player",FA,"WR1","6","Coach Upside rating","Coach Bust rating","4 out of 5 stars","-"`; describe("FantasyPros ranking import", () => { + it("rejects scoring-specific rankings from a different league format", () => { + const state = createMockDraftState(0); + state.settings.scoring = "Standard"; + const storedImport = importFantasyProsCsv(state, csv, "PPR"); + + expect(isDraftRankingImportCompatible(state, storedImport)).toBe(false); + }); + it("matches FantasyPros rows to draft players and applies imported rank metadata", () => { const state = createMockDraftState(0); const storedImport = importFantasyProsCsv(state, csv); diff --git a/apps/api/src/rankings-import.ts b/apps/api/src/rankings-import.ts index dc24ffa..2a9c853 100644 --- a/apps/api/src/rankings-import.ts +++ b/apps/api/src/rankings-import.ts @@ -2,7 +2,7 @@ import { existsSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import type { DraftState, Player, Position, RankingImportSummary } from "@sleeper-draft-assistant/shared"; +import type { DraftScoringFormat, DraftState, Player, Position, RankingImportSummary } from "@sleeper-draft-assistant/shared"; import type { SqliteAppDatabase } from "./sqlite-app-database"; import { readPrivateTextFile, removePrivateFile, writePrivateFile } from "./secure-file"; @@ -92,7 +92,9 @@ export class RankingImportStore { apply(draftId: string, state: DraftState): DraftState { const storedImport = this.get(draftId); - return storedImport ? applyImportedRankings(state, storedImport) : state; + return storedImport && isDraftRankingImportCompatible(state, storedImport) + ? applyImportedRankings(state, storedImport) + : state; } private load() { @@ -153,7 +155,11 @@ function deserializeRankingImport(storedImport: SerializedRankingImport): Stored }; } -export function importFantasyProsCsv(state: DraftState, csvText: string): StoredRankingImport { +export function importFantasyProsCsv( + state: DraftState, + csvText: string, + scoring: DraftScoringFormat = normalizeDraftScoringFormat(state.settings.scoring), +): StoredRankingImport { const rows = parseFantasyProsRows(csvText); const playersById = new Map(); const unmatched: RankingImportSummary["unmatched"] = []; @@ -199,12 +205,38 @@ export function importFantasyProsCsv(state: DraftState, csvText: string): Stored matched: playersById.size, unmatched: unmatched.slice(0, 40), ambiguous: ambiguous.slice(0, 40), + scoring, appliedAt: new Date().toISOString(), }, playersById, }; } +export function normalizeDraftScoringFormat(scoring: string): DraftScoringFormat { + const normalized = scoring.trim().toLowerCase(); + if (normalized === "ppr") { + return "PPR"; + } + if (normalized === "half ppr" || normalized === "half-ppr") { + return "Half PPR"; + } + if (normalized === "standard") { + return "Standard"; + } + return normalized ? "Custom" : "Unknown"; +} + +export function isDraftRankingImportCompatible( + state: Pick, + storedImport: Pick, +): boolean { + const imported = storedImport.summary.scoring; + if (!imported || imported === "Unknown") { + return true; + } + return imported === normalizeDraftScoringFormat(state.settings.scoring); +} + export function applyImportedRankings(state: DraftState, storedImport: StoredRankingImport): DraftState { return { ...state, diff --git a/apps/api/src/recommendations.test.ts b/apps/api/src/recommendations.test.ts index ab7a844..c409c74 100644 --- a/apps/api/src/recommendations.test.ts +++ b/apps/api/src/recommendations.test.ts @@ -1,4 +1,11 @@ -import type { AppSettings, DraftRecommendation, DraftState, RankingImportSummary } from "@sleeper-draft-assistant/shared"; +import type { + AdpImportSummary, + AppSettings, + DraftRecommendation, + DraftState, + RankingImportSummary, + SeasonProjectionImportSummary, +} from "@sleeper-draft-assistant/shared"; import { describe, expect, it } from "vitest"; import { app } from "./index"; @@ -7,6 +14,8 @@ type DraftPayload = { state: DraftState; recommendation: DraftRecommendation; rankingImportSummary: RankingImportSummary | null; + seasonProjectionImportSummary: SeasonProjectionImportSummary | null; + adpImportSummary: AdpImportSummary | null; }; type AskPayload = { @@ -19,8 +28,30 @@ const fantasyProsCsv = `"RK",TIERS,"PLAYER NAME",TEAM,"POS","BYE WEEK","UPSIDE " "1",1,"Josh Allen",BUF,"QB1","7","","","4 out of 5 stars","+8" "2",1,"Jahmyr Gibbs",DET,"RB1","6","","","5 out of 5 stars","0" "3",1,"A.J. Brown",PHI,"WR1","9","","","3 out of 5 stars","+2"`; +const fantasyProsQbProjectionsCsv = `"Player","Team","ATT","CMP","YDS","TDS","INTS","ATT","YDS","TDS","FL","FPTS" +"Josh Allen","BUF","500","330","4,000","30","10","100","500","10","2","365"`; +const fantasyProsAdpCsv = `Rank,Player (Bye),POS,Sleeper,RTSports,AVG,Real-Time +1,Jahmyr Gibbs DET (6),RB1,2,-,2.0,1 +2,Josh Allen BUF (7),QB1,18,-,18.0,16`; describe("draft recommendation routes", () => { + it("rejects rankings for a different scoring format", async () => { + const response = await app.request("/drafts/mock-draft/rankings/import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + source: "fantasypros", + scoring: "Standard", + csvText: fantasyProsCsv, + }), + }); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: "The Standard rankings do not match this PPR league.", + }); + }); + it("applies preference ids to mock draft recommendations", async () => { const baselineResponse = await app.request("/drafts/mock/recommendations"); expect(baselineResponse.status).toBe(200); @@ -54,11 +85,15 @@ describe("draft recommendation routes", () => { try { await updateSettings({ ...originalSettings, aiProvider: "noop" }); await app.request("/drafts/mock-draft/rankings/import", { method: "DELETE" }); + await app.request("/drafts/mock-draft/projections/season/import", { method: "DELETE" }); + await app.request("/drafts/mock-draft/adp/import", { method: "DELETE" }); const initialStateResponse = await app.request("/drafts/mock-draft/state"); expect(initialStateResponse.status).toBe(200); const initialPayload = (await initialStateResponse.json()) as DraftPayload; expect(initialPayload.rankingImportSummary).toBeNull(); + expect(initialPayload.seasonProjectionImportSummary).toBeNull(); + expect(initialPayload.adpImportSummary).toBeNull(); const initialDecisionsResponse = await app.request("/drafts/mock-draft/decisions?limit=5"); expect(initialDecisionsResponse.status).toBe(200); @@ -83,6 +118,42 @@ describe("draft recommendation routes", () => { expect(importedPayload.rankingImportSummary?.matched).toBe(3); expect(importedPayload.recommendation.candidates[0]?.reasons[0]).toContain("FantasyPros rank"); + const projectionResponse = await app.request("/drafts/mock-draft/projections/season/import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + source: "fantasypros", + season: "2026", + files: [{ position: "QB", csvText: fantasyProsQbProjectionsCsv }], + }), + }); + expect(projectionResponse.status).toBe(200); + const projectionPayload = (await projectionResponse.json()) as DraftPayload & { summary: SeasonProjectionImportSummary }; + expect(projectionPayload.summary.matched).toBe(1); + expect(projectionPayload.state.players.find((player) => player.name === "Josh Allen")).toMatchObject({ + projectionSource: "season_projection", + seasonProjectionSeason: "2026", + }); + + const adpResponse = await app.request("/drafts/mock-draft/adp/import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ source: "fantasypros", season: "2026", csvText: fantasyProsAdpCsv }), + }); + expect(adpResponse.status).toBe(200); + const adpPayload = (await adpResponse.json()) as DraftPayload & { summary: AdpImportSummary }; + expect(adpPayload.summary.matched).toBe(2); + expect(adpPayload.state.players.find((player) => player.name === "Josh Allen")).toMatchObject({ + adp: 18, + realTimeAdp: 16, + }); + + const draftDataReloadResponse = await app.request("/drafts/mock-draft/state"); + const draftDataReload = (await draftDataReloadResponse.json()) as DraftPayload; + expect(draftDataReload.rankingImportSummary?.matched).toBe(3); + expect(draftDataReload.seasonProjectionImportSummary?.matched).toBe(1); + expect(draftDataReload.adpImportSummary?.matched).toBe(2); + const excludedId = importedPayload.recommendation.candidates[0]?.player.id; const fadedId = importedPayload.recommendation.candidates[1]?.player.id; expect(excludedId).toBeTruthy(); @@ -123,6 +194,9 @@ describe("draft recommendation routes", () => { expect(decisions.snapshots.some((snapshot) => snapshot.trigger === "rankings-import")).toBe(true); expect(decisions.snapshots.some((snapshot) => snapshot.trigger === "ai-question")).toBe(true); } finally { + await app.request("/drafts/mock-draft/rankings/import", { method: "DELETE" }); + await app.request("/drafts/mock-draft/projections/season/import", { method: "DELETE" }); + await app.request("/drafts/mock-draft/adp/import", { method: "DELETE" }); await updateSettings(originalSettings); } }); diff --git a/apps/api/src/ros-rankings-import.test.ts b/apps/api/src/ros-rankings-import.test.ts new file mode 100644 index 0000000..bb5cc65 --- /dev/null +++ b/apps/api/src/ros-rankings-import.test.ts @@ -0,0 +1,67 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { createMockDraftState } from "@sleeper-draft-assistant/engine"; +import { describe, expect, it } from "vitest"; + +import { + RosRankingImportStore, + applyRosRankingsToPlayers, + importFantasyProsRosRankings, + isRosScoringCompatible, +} from "./ros-rankings-import"; +import { SqliteAppDatabase } from "./sqlite-app-database"; + +const rosCsv = `"RK","PLAYER NAME",TEAM,"POS","BEST","WORST","AVG.","STD.DEV","ECR VS. ADP" +"1","Jahmyr Gibbs",DET,"RB1","1","5","2.6","1.1","-" +"2","Josh Allen",BUF,"QB1","20","30","25.0","3.2","-"`; + +describe("FantasyPros rest-of-season ranking imports", () => { + it("requires the imported scoring format to match the league", () => { + expect(isRosScoringCompatible("PPR", "PPR")).toBe(true); + expect(isRosScoringCompatible("PPR", "Half PPR")).toBe(false); + expect(isRosScoringCompatible("Custom", "PPR")).toBe(false); + }); + + it("imports overall ranks and expert disagreement", () => { + const players = createMockDraftState(0).players; + const storedImport = importFantasyProsRosRankings({ + players, + season: "2025", + scoring: "PPR", + csvText: rosCsv, + }); + const rankedPlayers = applyRosRankingsToPlayers(players, storedImport); + + expect(storedImport.summary).toMatchObject({ + season: "2025", + scoring: "PPR", + rowsParsed: 2, + matched: 2, + }); + expect(rankedPlayers.find((player) => player.name === "Jahmyr Gibbs")).toMatchObject({ + rosRank: 1, + rosPositionRank: 1, + rosBestRank: 1, + rosWorstRank: 5, + rosAverageRank: 2.6, + rosStdDev: 1.1, + rosSource: "FantasyPros", + }); + }); + + it("persists rankings by league, season, and scoring", async () => { + const players = createMockDraftState(0).players; + const dir = mkdtempSync(path.join(tmpdir(), "sda-ros-rankings-")); + const database = await SqliteAppDatabase.open(path.join(dir, "app.sqlite")); + const store = new RosRankingImportStore(path.join(dir, "ros.json"), database); + const key = { leagueId: "league-1", season: "2025", scoring: "PPR" as const }; + + store.set(key, importFantasyProsRosRankings({ players, season: "2025", scoring: "PPR", csvText: rosCsv })); + + const reopenedDatabase = await SqliteAppDatabase.open(path.join(dir, "app.sqlite")); + const reopened = new RosRankingImportStore(path.join(dir, "ros.json"), reopenedDatabase); + expect(reopened.get(key)?.summary.matched).toBe(2); + }); +}); diff --git a/apps/api/src/ros-rankings-import.ts b/apps/api/src/ros-rankings-import.ts new file mode 100644 index 0000000..6722d5a --- /dev/null +++ b/apps/api/src/ros-rankings-import.ts @@ -0,0 +1,438 @@ +import { existsSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import type { + DraftScoringFormat, + Player, + Position, + RosRankingImportSummary, + TeamManagerState, +} from "@sleeper-draft-assistant/shared"; + +import type { SqliteAppDatabase } from "./sqlite-app-database"; +import { readPrivateTextFile, removePrivateFile, writePrivateFile } from "./secure-file"; + +export type RosRankingImportKey = { + leagueId: string; + season: string; + scoring: DraftScoringFormat; +}; + +type ImportedRosRanking = { + rank: number; + positionRank: number | null; + bestRank: number | null; + worstRank: number | null; + averageRank: number | null; + standardDeviation: number | null; +}; + +export type StoredRosRankingImport = { + summary: RosRankingImportSummary; + playersById: Map; +}; + +type SerializedRosRankingImport = { + summary: RosRankingImportSummary; + players: Array<[string, ImportedRosRanking]>; +}; + +type RosRow = { + rowNumber: number; + name: string; + team: string | null; + position: Position | null; + rank: number | null; + positionRank: number | null; + bestRank: number | null; + worstRank: number | null; + averageRank: number | null; + standardDeviation: number | null; +}; + +export class RosRankingImportStore { + private readonly imports = new Map(); + + constructor( + private readonly filePath = getDefaultStorePath(), + private readonly database?: SqliteAppDatabase, + ) { + this.load(); + } + + set(key: RosRankingImportKey, storedImport: StoredRosRankingImport) { + const importKey = toRosImportKey(key); + this.imports.set(importKey, storedImport); + if (this.database) { + this.database.setJson("ros_ranking_imports", importKey, serialize(storedImport)); + } else { + this.saveFile(); + } + } + + get(key: RosRankingImportKey): StoredRosRankingImport | null { + return this.imports.get(toRosImportKey(key)) ?? null; + } + + delete(key: RosRankingImportKey): boolean { + const importKey = toRosImportKey(key); + const deleted = this.imports.delete(importKey); + if (deleted) { + if (this.database) { + this.database.deleteJson("ros_ranking_imports", importKey); + } else { + this.saveFile(); + } + } + return deleted; + } + + clearAll(): number { + const deleted = this.imports.size; + this.imports.clear(); + if (this.database) { + this.database.clearJson("ros_ranking_imports"); + } else { + this.saveFile(); + } + removePrivateFile(this.filePath); + return deleted; + } + + private load() { + if (this.database) { + const records = this.database.listJson("ros_ranking_imports"); + if (records.length > 0) { + for (const [key, value] of records) { + this.imports.set(key, deserialize(value)); + } + return; + } + } + if (!existsSync(this.filePath)) { + return; + } + try { + const parsed = JSON.parse(readPrivateTextFile(this.filePath)) as Record; + for (const [key, value] of Object.entries(parsed)) { + const storedImport = deserialize(value); + this.imports.set(key, storedImport); + this.database?.setJson("ros_ranking_imports", key, serialize(storedImport)); + } + } catch { + this.imports.clear(); + } + } + + private saveFile() { + const serialized = Object.fromEntries( + Array.from(this.imports.entries()).map(([key, value]) => [key, serialize(value)]), + ); + writePrivateFile(this.filePath, `${JSON.stringify(serialized, null, 2)}\n`); + } +} + +export function importFantasyProsRosRankings(input: { + players: Player[]; + season: string; + scoring: DraftScoringFormat; + csvText: string; +}): StoredRosRankingImport { + const rows = parseRosRows(input.csvText); + const playersById = new Map(); + const unmatched: RosRankingImportSummary["unmatched"] = []; + const ambiguous: RosRankingImportSummary["ambiguous"] = []; + + for (const row of rows) { + if (!row.rank || !row.position) { + unmatched.push(toMatchIssue(row)); + continue; + } + const match = matchPlayer(row, input.players); + if (match.kind === "matched") { + playersById.set(match.player.id, { + rank: row.rank, + positionRank: row.positionRank, + bestRank: row.bestRank, + worstRank: row.worstRank, + averageRank: row.averageRank, + standardDeviation: row.standardDeviation, + }); + } else if (match.kind === "ambiguous") { + ambiguous.push({ + ...toMatchIssue(row), + candidates: match.players.map((player) => `${player.name} (${player.team} ${player.position})`), + }); + } else { + unmatched.push(toMatchIssue(row)); + } + } + + return { + summary: { + source: "fantasypros", + season: input.season, + scoring: input.scoring, + rowsParsed: rows.length, + matched: playersById.size, + unmatched: unmatched.slice(0, 40), + ambiguous: ambiguous.slice(0, 40), + appliedAt: new Date().toISOString(), + }, + playersById, + }; +} + +export function applyRosRankingsToTeamState( + state: TeamManagerState, + storedImport: StoredRosRankingImport | null, +): TeamManagerState { + if (!storedImport) { + return state; + } + return { + ...state, + roster: { + ...state.roster, + starters: state.roster.starters.map((slot) => ({ + ...slot, + player: slot.player ? applyRosRankingToPlayer(slot.player, storedImport) : null, + })), + bench: applyRosRankingsToPlayers(state.roster.bench, storedImport), + injuredReserve: applyRosRankingsToPlayers(state.roster.injuredReserve, storedImport), + taxi: applyRosRankingsToPlayers(state.roster.taxi, storedImport), + }, + dataQuality: { + ...state.dataQuality, + playerValueSource: `FantasyPros ${storedImport.summary.season} ${storedImport.summary.scoring} rest-of-season ECR`, + limitations: state.dataQuality.limitations.filter( + (item) => !item.toLowerCase().includes("rest-of-season"), + ), + }, + }; +} + +export function applyRosRankingsToPlayers( + players: Player[], + storedImport: StoredRosRankingImport | null, +): Player[] { + return storedImport + ? players.map((player) => applyRosRankingToPlayer(player, storedImport)) + : players; +} + +export function applyRosRankingToPlayer( + player: Player, + storedImport: StoredRosRankingImport, +): Player { + const value = storedImport.playersById.get(player.id); + if (!value) { + return player; + } + return { + ...player, + rosRank: value.rank, + rosPositionRank: value.positionRank, + rosBestRank: value.bestRank, + rosWorstRank: value.worstRank, + rosAverageRank: value.averageRank, + rosStdDev: value.standardDeviation, + rosSource: "FantasyPros", + rosSeason: storedImport.summary.season, + rosScoring: storedImport.summary.scoring, + }; +} + +export function isRosRankingImportActive( + state: Pick, + storedImport: StoredRosRankingImport | null, +): boolean { + if (!storedImport || !state.league.season) { + return false; + } + return storedImport.summary.season === state.league.season + && isRosScoringCompatible(storedImport.summary.scoring, state.league.scoring); +} + +export function normalizeScoringFormat(value: string): DraftScoringFormat { + const normalized = value.trim().toLowerCase(); + if (normalized === "ppr") return "PPR"; + if (normalized === "half ppr" || normalized === "half-ppr") return "Half PPR"; + if (normalized === "standard") return "Standard"; + return normalized ? "Custom" : "Unknown"; +} + +export function toRosImportKey(key: RosRankingImportKey): string { + return `${key.leagueId}:${key.season}:${key.scoring}`; +} + +export function isRosScoringCompatible(imported: DraftScoringFormat, leagueScoring: string): boolean { + return imported === normalizeScoringFormat(leagueScoring); +} + +function parseRosRows(csvText: string): RosRow[] { + const [headers, ...records] = parseCsv(csvText.trim()); + if (!headers) { + return []; + } + const indexes = new Map( + headers.map((header, index) => [normalizeHeader(header), index]), + ); + return records + .map((record, index) => { + const positionText = cell(record, indexes, "pos"); + const parsedPosition = parsePosition(positionText); + return { + rowNumber: index + 2, + name: cleanText(cell(record, indexes, "playername")), + team: nullableText(cell(record, indexes, "team")), + position: parsedPosition.position, + rank: numberFrom(cell(record, indexes, "rk")), + positionRank: parsedPosition.rank, + bestRank: numberFrom(cell(record, indexes, "best")), + worstRank: numberFrom(cell(record, indexes, "worst")), + averageRank: numberFrom(cell(record, indexes, "avg")), + standardDeviation: numberFrom(cell(record, indexes, "stddev")), + }; + }) + .filter((row) => row.name.length > 0); +} + +function parsePosition(value: string): { position: Position | null; rank: number | null } { + const match = cleanText(value).toUpperCase().match(/^(QB|RB|WR|TE|K|DST|DEF)(\d+)?$/); + if (!match) { + return { position: null, rank: null }; + } + return { + position: match[1] === "DST" ? "DEF" : match[1] as Position, + rank: match[2] ? Number(match[2]) : null, + }; +} + +function matchPlayer(row: RosRow, players: Player[]) { + const normalizedName = normalizeName(row.name); + const candidates = players.filter((player) => + player.position === row.position && normalizeName(player.name) === normalizedName, + ); + const teamMatches = row.team + ? candidates.filter((player) => normalizeTeam(player.team) === normalizeTeam(row.team!)) + : []; + const matches = teamMatches.length > 0 ? teamMatches : candidates; + if (matches.length === 1) { + return { kind: "matched" as const, player: matches[0]! }; + } + if (matches.length > 1) { + return { kind: "ambiguous" as const, players: matches }; + } + return { kind: "unmatched" as const }; +} + +function toMatchIssue(row: RosRow) { + return { + row: row.rowNumber, + name: row.name, + team: row.team, + position: row.position, + }; +} + +function normalizeName(name: string): string { + const normalized = cleanText(name) + .toLowerCase() + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/\b(jr|sr|ii|iii|iv|v)\b/g, "") + .replace(/[^a-z0-9]/g, ""); + return playerNameAliases[normalized] ?? normalized; +} + +const playerNameAliases: Record = { + bamknight: "zonovanknight", + hollywoodbrown: "marquisebrown", +}; + +function normalizeTeam(team: string): string { + return team.toUpperCase() === "JAC" ? "JAX" : team.toUpperCase(); +} + +function normalizeHeader(value: string): string { + return cleanText(value).toLowerCase().replace(/[^a-z0-9]/g, ""); +} + +function cell(record: string[], indexes: Map, header: string): string { + const index = indexes.get(header); + return index === undefined ? "" : record[index] ?? ""; +} + +function cleanText(value: string): string { + return value.replace(/\u00c2/g, "").replace(/\u00a0/g, " ").trim(); +} + +function nullableText(value: string): string | null { + const cleaned = cleanText(value); + return cleaned || null; +} + +function numberFrom(value: string): number | null { + const cleaned = cleanText(value).replace(/,/g, ""); + if (!/^[+-]?\d+(?:\.\d+)?$/.test(cleaned)) { + return null; + } + const parsed = Number(cleaned); + return Number.isFinite(parsed) ? parsed : null; +} + +function parseCsv(text: string): string[][] { + const rows: string[][] = []; + let row: string[] = []; + let cellValue = ""; + let inQuotes = false; + for (let index = 0; index < text.length; index += 1) { + const char = text[index]; + const next = text[index + 1]; + if (char === "\"") { + if (inQuotes && next === "\"") { + cellValue += "\""; + index += 1; + } else { + inQuotes = !inQuotes; + } + } else if (char === "," && !inQuotes) { + row.push(cellValue); + cellValue = ""; + } else if ((char === "\n" || char === "\r") && !inQuotes) { + if (char === "\r" && next === "\n") index += 1; + row.push(cellValue); + if (row.some((value) => value.trim())) rows.push(row); + row = []; + cellValue = ""; + } else { + cellValue += char; + } + } + row.push(cellValue); + if (row.some((value) => value.trim())) rows.push(row); + return rows; +} + +function serialize(storedImport: StoredRosRankingImport): SerializedRosRankingImport { + return { + summary: storedImport.summary, + players: Array.from(storedImport.playersById.entries()), + }; +} + +function deserialize(value: SerializedRosRankingImport): StoredRosRankingImport { + return { + summary: value.summary, + playersById: new Map(value.players), + }; +} + +function getDefaultStorePath(): string { + const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); + return process.env.SLEEPER_AI_DATA_DIR + ? path.join(process.env.SLEEPER_AI_DATA_DIR, "ros-ranking-imports.json") + : path.join(repoRoot, "data", "ros-ranking-imports.json"); +} diff --git a/apps/api/src/sleeper.test.ts b/apps/api/src/sleeper.test.ts index 3e6acae..ea06f04 100644 --- a/apps/api/src/sleeper.test.ts +++ b/apps/api/src/sleeper.test.ts @@ -141,6 +141,7 @@ describe("Sleeper draft normalization", () => { expect(state.currentPick).toBe(4); expect(state.userTeamId).toBe("roster-12"); expect(state.settings.scoring).toBe("PPR"); + expect(state.settings.formatCompatibility).toMatchObject({ level: "supported" }); expect(state.settings.rosterSlots).toMatchObject({ QB: 1, RB: 2, WR: 2, TE: 1, FLEX: 1, BN: 2 }); expect(state.teams.map((team) => team.name)).toEqual(["Alpha", "Bravo Squad", "Charlie", "delta"]); expect(state.teams[1]?.roster).toEqual(["p2"]); @@ -149,6 +150,39 @@ describe("Sleeper draft normalization", () => { expect(state.players.find((player) => player.id === "p2")?.riskTags).toEqual(["injury: Questionable"]); }); + it("normalizes half-PPR superflex as a supported format", () => { + const state = normalizeSleeperDraftState({ + ...fixture, + league: { + ...fixture.league!, + scoring_settings: { rec: 0.5 }, + roster_positions: ["QB", "RB", "RB", "WR", "WR", "TE", "SUPER_FLEX", "BN"], + }, + }); + + expect(state.settings.scoring).toBe("Half PPR"); + expect(state.settings.formatCompatibility).toMatchObject({ + level: "supported", + features: ["superflex"], + }); + }); + + it("surfaces unsupported IDP and auction settings", () => { + const state = normalizeSleeperDraftState({ + ...fixture, + draft: { ...fixture.draft, type: "auction" }, + league: { + ...fixture.league!, + roster_positions: ["QB", "RB", "WR", "TE", "LB", "DB", "BN"], + }, + }); + + expect(state.settings.formatCompatibility).toMatchObject({ + level: "unsupported", + features: expect.arrayContaining(["idp", "auction"]), + }); + }); + it("falls back to league roster count when slot mapping is absent", () => { const state = normalizeSleeperDraftState({ ...fixture, @@ -246,6 +280,7 @@ describe("Sleeper team manager normalization", () => { }); expect(state.league).toMatchObject({ id: "league-1", name: "Fixture League", scoring: "PPR", teams: 4 }); + expect(state.league.formatCompatibility).toMatchObject({ level: "supported" }); expect(state.userTeam).toMatchObject({ rosterId: "12", ownerId: "user-2", name: "Bravo Squad" }); expect(state.roster.starters.map((slot) => slot.slot)).toEqual(["QB", "RB", "RB", "WR", "WR", "TE", "FLEX"]); expect(state.roster.starters[0]?.player?.name).toBe("Player Three"); diff --git a/apps/api/src/sleeper.ts b/apps/api/src/sleeper.ts index 32cdac0..7ad603e 100644 --- a/apps/api/src/sleeper.ts +++ b/apps/api/src/sleeper.ts @@ -1,5 +1,7 @@ import type { DraftSettings, DraftState, Pick, Player, Position, Team, TeamActivitySummary, TeamManagerState, TeamWeekContext, TeamWeekPlayer } from "@sleeper-draft-assistant/shared"; +import { assessFormatCompatibility } from "./format-compatibility"; + const sleeperApiBaseUrl = "https://api.sleeper.app/v1"; const playerCacheTtlMs = 24 * 60 * 60 * 1000; const playerPoolLimit = 700; @@ -549,6 +551,10 @@ export function normalizeSleeperTeamManagerState(input: SleeperTeamManagerStateI .map((playerId) => playerById.get(playerId)) .filter(isPresent) .filter((player) => !assignedStarterIds.has(player.id) && !reserveIds.has(player.id) && !taxiIds.has(player.id)); + const scoring = getLeagueScoringLabel(input.league); + const rosterSlots = getLeagueRosterSlots(input.league); + const scoringSettings = getNumericScoringSettings(input.league.scoring_settings); + const formatCompatibility = assessFormatCompatibility({ scoring, scoringSettings, rosterSlots }); return { league: { @@ -557,8 +563,9 @@ export function normalizeSleeperTeamManagerState(input: SleeperTeamManagerStateI season: input.league.season ?? null, status: input.league.status ?? "unknown", teams: input.league.total_rosters ?? input.rosters.length, - scoring: getLeagueScoringLabel(input.league), - rosterSlots: getLeagueRosterSlots(input.league), + scoring, + rosterSlots, + formatCompatibility, }, userTeam: { rosterId: String(userRoster.roster_id), @@ -579,6 +586,7 @@ export function normalizeSleeperTeamManagerState(input: SleeperTeamManagerStateI limitations: [ "Sleeper roster data does not include full fantasy projections.", "Starter slots reflect Sleeper starter IDs when available and roster settings otherwise.", + ...formatCompatibility.warnings, ], }, }; @@ -794,14 +802,32 @@ function buildDraftName(input: SleeperDraftStateInput): string { } function buildDraftSettings(input: SleeperDraftStateInput, teams: number, rounds: number): DraftSettings { + const scoring = getScoringLabel(input); + const scoringSettings = getNumericScoringSettings(input.league?.scoring_settings); + const rosterSlots = getRosterSlots(input); return { teams, rounds, - scoring: getScoringLabel(input), - rosterSlots: getRosterSlots(input), + scoring, + scoringSettings, + rosterSlots, + formatCompatibility: assessFormatCompatibility({ + scoring, + scoringSettings, + rosterSlots, + draftType: input.draft.type, + }), }; } +function getNumericScoringSettings(settings: JsonRecord | null | undefined): Record { + return Object.fromEntries( + Object.entries(settings ?? {}) + .map(([key, value]) => [key, numberFrom(value)] as const) + .filter((entry): entry is [string, number] => entry[1] !== null), + ); +} + function getScoringLabel(input: SleeperDraftStateInput): string { const metadataScoring = stringFrom(input.draft.metadata?.scoring_type); if (metadataScoring) { diff --git a/apps/api/src/sqlite-app-database.ts b/apps/api/src/sqlite-app-database.ts index 168e849..b9383a0 100644 --- a/apps/api/src/sqlite-app-database.ts +++ b/apps/api/src/sqlite-app-database.ts @@ -8,7 +8,13 @@ import type { Database } from "sql.js"; import { ensurePrivateDirectory, readPrivateFile, writePrivateFile } from "./secure-file"; -export type JsonNamespace = "settings" | "ranking_imports" | "weekly_projection_imports"; +export type JsonNamespace = + | "settings" + | "ranking_imports" + | "season_projection_imports" + | "adp_imports" + | "ros_ranking_imports" + | "weekly_projection_imports"; export class SqliteAppDatabase { private constructor( diff --git a/apps/api/src/weekly-projections-import.ts b/apps/api/src/weekly-projections-import.ts index f9b002f..7e5f765 100644 --- a/apps/api/src/weekly-projections-import.ts +++ b/apps/api/src/weekly-projections-import.ts @@ -275,7 +275,10 @@ export function applyWeeklyProjectionsToTeamState(state: TeamManagerState, store dataQuality: { ...state.dataQuality, playerValueSource: `FantasyPros weekly projections for ${storedImport.summary.season} Week ${storedImport.summary.week}`, - limitations: state.dataQuality.limitations.filter((item) => !item.toLowerCase().includes("weekly projections")), + limitations: [ + ...state.dataQuality.limitations.filter((item) => !item.toLowerCase().includes("weekly projections")), + "Weekly FPTS are provider-scored; the FantasyPros export scoring must match the Sleeper league.", + ], }, }; diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 9fece6d..20e9434 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@sleeper-draft-assistant/desktop", - "version": "0.1.0-alpha.3", + "version": "0.1.0-alpha.4", "private": true, "main": "src/main.cjs", "description": "Unofficial, local-first fantasy football draft and team-management assistant for Sleeper.", diff --git a/apps/web/package.json b/apps/web/package.json index 0b8c9a0..0fae765 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@sleeper-draft-assistant/web", - "version": "0.1.0-alpha.3", + "version": "0.1.0-alpha.4", "private": true, "type": "module", "scripts": { @@ -12,7 +12,7 @@ }, "dependencies": { "@fontsource/inter": "^5.2.8", - "@sleeper-draft-assistant/shared": "0.1.0-alpha.3", + "@sleeper-draft-assistant/shared": "0.1.0-alpha.4", "@sveltejs/vite-plugin-svelte": "^6.2.1", "svelte": "^5.43.14", "vite": "^7.2.7" diff --git a/apps/web/src/App.svelte b/apps/web/src/App.svelte index dda45e2..547b48e 100644 --- a/apps/web/src/App.svelte +++ b/apps/web/src/App.svelte @@ -19,14 +19,19 @@ import TeamLineupPanel from "./lib/components/TeamLineupPanel.svelte"; import TeamWeekPanel from "./lib/components/TeamWeekPanel.svelte"; import TeamWaiverPanel from "./lib/components/TeamWaiverPanel.svelte"; + import RosRankingsImportPanel from "./lib/components/RosRankingsImportPanel.svelte"; import WeeklyProjectionsImportPanel from "./lib/components/WeeklyProjectionsImportPanel.svelte"; import TeamRefreshStatus from "./lib/components/TeamRefreshStatus.svelte"; + import FormatCompatibilityNotice from "./lib/components/FormatCompatibilityNotice.svelte"; import PickFeedPanel from "./lib/components/PickFeedPanel.svelte"; import AskManagerPanel from "./lib/components/AskManagerPanel.svelte"; import { askManagerRequest, askTeamManagerRequest, + clearAdpRequest, + clearRosRankingsRequest, + clearSeasonProjectionsRequest, clearWeeklyProjectionsRequest, clearRankingsRequest, createDraftEventSource, @@ -38,7 +43,10 @@ fetchSleeperConnect, fetchTeamManagerState, importWeeklyProjectionFilesRequest, + importAdpRequest, importRankingsRequest, + importRosRankingsRequest, + importSeasonProjectionsRequest, updateSettings, } from "./lib/api"; import { draftSlotToRosterFallback, getDraftPhase, getUserTeam, isMockDraft, preferredWorkspaceMode } from "./lib/format"; @@ -56,8 +64,12 @@ ConnectPayload, DraftPayload, DraftRecommendation, + DraftScoringFormat, DraftState, + AdpImportSummary, RankingImportSummary, + RosRankingImportSummary, + SeasonProjectionImportSummary, TeamActivitySummary, TeamDataReadiness, TeamLineupSummary, @@ -91,6 +103,8 @@ let activeUserRosterId: string | null = $state(null); let loadError = $state(""); let rankingImportSummary: RankingImportSummary | null = $state(null); + let seasonProjectionImportSummary: SeasonProjectionImportSummary | null = $state(null); + let adpImportSummary: AdpImportSummary | null = $state(null); let teamManagerState: TeamManagerState | null = $state(null); let teamDataReadiness: TeamDataReadiness | null = $state(null); let teamNeeds: TeamNeedsSummary | null = $state(null); @@ -99,11 +113,15 @@ let teamWaiverSummary: TeamWaiverSummary | null = $state(null); let teamActivitySummary: TeamActivitySummary | null = $state(null); let weeklyProjectionSummary: WeeklyProjectionImportSummary | null = $state(null); + let rosRankingSummary: RosRankingImportSummary | null = $state(null); let teamProjectionSeason = $state(""); let teamProjectionWeek = $state(0); let weeklyProjectionError = $state(""); + let rosRankingError = $state(""); let isImportingWeeklyProjections = $state(false); let isClearingWeeklyProjections = $state(false); + let isImportingRosRankings = $state(false); + let isClearingRosRankings = $state(false); let teamManagerError = $state(""); let isLoadingTeamManager = $state(false); let isRefreshingTeamManager = $state(false); @@ -115,8 +133,14 @@ let teamRefreshInterval: ReturnType | null = null; let playerPreferences: PlayerPreferences = $state({}); let rankingImportError = $state(""); + let seasonProjectionImportError = $state(""); + let adpImportError = $state(""); let isImportingRankings = $state(false); let isClearingRankings = $state(false); + let isImportingSeasonProjections = $state(false); + let isClearingSeasonProjections = $state(false); + let isImportingAdp = $state(false); + let isClearingAdp = $state(false); let isLoading = $state(false); let isConnecting = $state(false); let isSavingSettings = $state(false); @@ -458,6 +482,8 @@ draftState = null; recommendation = null; rankingImportSummary = null; + seasonProjectionImportSummary = null; + adpImportSummary = null; activeDraftId = ""; activeUserRosterId = null; teamManagerState = null; @@ -468,9 +494,13 @@ teamWaiverSummary = null; teamActivitySummary = null; weeklyProjectionSummary = null; + rosRankingSummary = null; teamProjectionSeason = ""; teamProjectionWeek = 0; weeklyProjectionError = ""; + rosRankingError = ""; + seasonProjectionImportError = ""; + adpImportError = ""; teamManagerError = ""; connectExpanded = true; rankingsExpanded = false; @@ -497,7 +527,9 @@ teamProjectionSeason = ""; teamProjectionWeek = 0; weeklyProjectionSummary = null; + rosRankingSummary = null; weeklyProjectionError = ""; + rosRankingError = ""; } applyDraftPayload(payload); activeDraftId = draftId; @@ -508,7 +540,11 @@ void refreshRecommendationWithPreferences(); } connectExpanded = false; - rankingsExpanded = !isMockDraft(draftId) && !payload.rankingImportSummary; + rankingsExpanded = !isMockDraft(draftId) && ( + !payload.rankingImportSummary || + !payload.seasonProjectionImportSummary || + !payload.adpImportSummary + ); if (isMockDraft(draftId)) { window.localStorage.removeItem("lastDraftId"); window.localStorage.removeItem("lastUserRosterId"); @@ -534,6 +570,8 @@ draftState = null; recommendation = null; rankingImportSummary = null; + seasonProjectionImportSummary = null; + adpImportSummary = null; activeDraftId = ""; activeUserRosterId = null; teamManagerState = null; @@ -544,9 +582,13 @@ teamWaiverSummary = null; teamActivitySummary = null; weeklyProjectionSummary = null; + rosRankingSummary = null; teamProjectionSeason = ""; teamProjectionWeek = 0; weeklyProjectionError = ""; + rosRankingError = ""; + seasonProjectionImportError = ""; + adpImportError = ""; teamManagerError = ""; resetTeamRefreshTracking(); connectExpanded = true; @@ -579,7 +621,9 @@ teamWaiverSummary = null; teamActivitySummary = null; weeklyProjectionSummary = null; + rosRankingSummary = null; weeklyProjectionError = ""; + rosRankingError = ""; teamManagerError = ""; isLoadingTeamManager = false; return; @@ -634,6 +678,7 @@ teamWaiverSummary = null; teamActivitySummary = null; weeklyProjectionSummary = null; + rosRankingSummary = null; teamManagerError = message; } } finally { @@ -685,6 +730,7 @@ teamWeekContext = payload.weekContext; teamWaiverSummary = payload.waiverSummary; teamActivitySummary = payload.activitySummary; + rosRankingSummary = payload.rosRankingSummary; weeklyProjectionSummary = payload.weeklyProjectionSummary; if (!teamProjectionSeason) { teamProjectionSeason = payload.state.league.season ?? ""; @@ -698,6 +744,8 @@ draftState = payload.state; recommendation = payload.recommendation; rankingImportSummary = payload.rankingImportSummary; + seasonProjectionImportSummary = payload.seasonProjectionImportSummary; + adpImportSummary = payload.adpImportSummary; if (hasPlayerPreferences()) { void refreshRecommendationWithPreferences(); } @@ -763,11 +811,13 @@ rankingImportError = ""; try { - const payload = await importRankingsRequest(activeDraftId, activeUserRosterId, csvText); - rankingImportSummary = payload.summary; - draftState = payload.state; - recommendation = payload.recommendation; - rankingsExpanded = false; + const payload = await importRankingsRequest( + activeDraftId, + activeUserRosterId, + csvText, + normalizeDraftScoring(draftState?.settings.scoring), + ); + applyDraftPayload(payload); if (teamManagerState) { void loadTeamManager(teamManagerState.league.id, activeUserRosterId); } @@ -797,7 +847,9 @@ void loadTeamManager(teamManagerState.league.id, activeUserRosterId); } status = "FantasyPros rankings cleared"; - lastEvent = "Recommendations returned to Sleeper placeholder values"; + lastEvent = seasonProjectionImportSummary + ? "Expert ranks cleared; season projections remain active" + : "Recommendations returned to Sleeper placeholder values"; } catch (error) { rankingImportError = error instanceof Error ? error.message : "Could not clear imported rankings."; } finally { @@ -805,8 +857,116 @@ } } + async function importSeasonProjections(input: { + season: string; + files: Array<{ position: Position; csvText: string }>; + }) { + if (!activeDraftId || !input.season || input.files.length === 0) { + seasonProjectionImportError = "Select a draft, season, and at least one projection CSV."; + return; + } + isImportingSeasonProjections = true; + seasonProjectionImportError = ""; + try { + const payload = await importSeasonProjectionsRequest({ + draftId: activeDraftId, + userRosterId: activeUserRosterId, + season: input.season, + files: input.files, + }); + applyDraftPayload(payload); + if (teamManagerState) { + void loadTeamManager(teamManagerState.league.id, activeUserRosterId); + } + status = "FantasyPros season projections imported"; + lastEvent = `${payload.summary.matched} projection rows matched`; + } catch (error) { + seasonProjectionImportError = error instanceof Error ? error.message : "Season projection import failed."; + } finally { + isImportingSeasonProjections = false; + } + } + + async function clearSeasonProjections() { + if (!activeDraftId) { + return; + } + isClearingSeasonProjections = true; + seasonProjectionImportError = ""; + try { + applyDraftPayload(await clearSeasonProjectionsRequest(activeDraftId, activeUserRosterId)); + status = "Season projections cleared"; + } catch (error) { + seasonProjectionImportError = error instanceof Error ? error.message : "Could not clear season projections."; + } finally { + isClearingSeasonProjections = false; + } + } + + async function importAdp(csvText: string, season: string) { + if (!activeDraftId || !season || !csvText) { + adpImportError = "Select a draft and upload the FantasyPros overall ADP CSV."; + return; + } + isImportingAdp = true; + adpImportError = ""; + try { + const payload = await importAdpRequest({ + draftId: activeDraftId, + userRosterId: activeUserRosterId, + season, + csvText, + }); + applyDraftPayload(payload); + status = "FantasyPros Sleeper ADP imported"; + lastEvent = `${payload.summary.matched} ADP rows matched`; + } catch (error) { + adpImportError = error instanceof Error ? error.message : "Sleeper ADP import failed."; + } finally { + isImportingAdp = false; + } + } + + async function clearAdp() { + if (!activeDraftId) { + return; + } + isClearingAdp = true; + adpImportError = ""; + try { + applyDraftPayload(await clearAdpRequest(activeDraftId, activeUserRosterId)); + status = "Sleeper ADP cleared"; + } catch (error) { + adpImportError = error instanceof Error ? error.message : "Could not clear Sleeper ADP."; + } finally { + isClearingAdp = false; + } + } + + function normalizeDraftScoring(scoring: string | null | undefined): DraftScoringFormat { + const normalized = scoring?.trim().toLowerCase(); + if (normalized === "ppr") return "PPR"; + if (normalized === "half ppr" || normalized === "half-ppr") return "Half PPR"; + if (normalized === "standard") return "Standard"; + return normalized ? "Custom" : "Unknown"; + } + function openFantasyProsRankings() { - window.open("https://www.fantasypros.com/nfl/rankings/consensus-cheatsheets.php", "_blank", "noopener,noreferrer"); + const scoring = normalizeDraftScoring(draftState?.settings.scoring); + const page = scoring === "PPR" + ? "ppr-cheatsheets.php" + : scoring === "Half PPR" + ? "half-point-ppr-cheatsheets.php" + : "consensus-cheatsheets.php"; + window.open(`https://www.fantasypros.com/nfl/rankings/${page}`, "_blank", "noopener,noreferrer"); + } + + function openFantasyProsSeasonProjections() { + window.open("https://www.fantasypros.com/nfl/projections/qb.php?week=draft", "_blank", "noopener,noreferrer"); + } + + function openFantasyProsAdp() { + window.open("https://www.fantasypros.com/nfl/adp/overall.php", "_blank", "noopener,noreferrer"); } function openFantasyProsWeeklyProjections(position: Position, week: number) { @@ -819,6 +979,69 @@ window.open(`https://www.fantasypros.com/nfl/projections/${fantasyProsPosition}.php${suffix}`, "_blank", "noopener,noreferrer"); } + function openFantasyProsRosRankings() { + const scoring = normalizeDraftScoring(teamManagerState?.league.scoring); + const page = scoring === "PPR" + ? "ros-ppr-overall.php" + : scoring === "Half PPR" + ? "ros-half-point-ppr-overall.php" + : "ros-overall.php"; + window.open(`https://www.fantasypros.com/nfl/rankings/${page}`, "_blank", "noopener,noreferrer"); + } + + async function importRosRankings(input: { + season: string; + scoring: DraftScoringFormat; + csvText: string; + }) { + if (!teamManagerState || !input.season || !input.csvText.trim()) { + rosRankingError = "Open a team, enter the season, and choose the Overall ROS rankings CSV."; + return; + } + isImportingRosRankings = true; + rosRankingError = ""; + try { + const payload = await importRosRankingsRequest({ + leagueId: teamManagerState.league.id, + season: input.season, + scoring: input.scoring, + csvText: input.csvText, + userRosterId: activeUserRosterId, + draftId: activeDraftId, + week: teamProjectionWeek || teamManagerState.week, + }); + applyTeamPayload(payload); + status = "FantasyPros rest-of-season rankings imported"; + lastEvent = `${payload.summary.matched} ROS ranking rows matched`; + } catch (error) { + rosRankingError = error instanceof Error ? error.message : "Rest-of-season ranking import failed."; + } finally { + isImportingRosRankings = false; + } + } + + async function clearRosRankings(input: { season: string; scoring: DraftScoringFormat }) { + if (!teamManagerState) { + return; + } + isClearingRosRankings = true; + rosRankingError = ""; + try { + await clearRosRankingsRequest(teamManagerState.league.id, input.season, input.scoring); + await loadTeamManager( + teamManagerState.league.id, + activeUserRosterId, + teamProjectionSeason || null, + teamProjectionWeek || null, + ); + status = "Rest-of-season rankings cleared"; + } catch (error) { + rosRankingError = error instanceof Error ? error.message : "Could not clear rest-of-season rankings."; + } finally { + isClearingRosRankings = false; + } + } + async function importWeeklyProjections(input: { files: Array<{ position: Position; csvText: string }>; season: string; @@ -941,13 +1164,11 @@ const isDemoDraftActive = $derived(Boolean(draftState && isMockDraft(activeDraftId))); const isRealDraftActive = $derived(Boolean(draftState && !isMockDraft(activeDraftId))); const hasImportedRankings = $derived(Boolean(rankingImportSummary)); - const importMatchRate = $derived.by(() => { - const summary = rankingImportSummary; - if (!summary || !summary.rowsParsed) { - return null; - } - return Math.round((summary.matched / summary.rowsParsed) * 100); - }); + const hasSeasonProjections = $derived(Boolean(seasonProjectionImportSummary)); + const hasImportedAdp = $derived(Boolean(adpImportSummary)); + const draftDataSignalCount = $derived( + Number(hasImportedRankings) + Number(hasSeasonProjections) + Number(hasImportedAdp), + ); const recommendationsUsePlaceholder = $derived.by(() => { const currentRecommendation = recommendation; return Boolean( @@ -1003,15 +1224,17 @@ }, { label: "Player values", - value: hasImportedRankings ? "Imported" : draftState ? "Import needed" : "Pending", - detail: hasImportedRankings - ? `${rankingImportSummary?.matched ?? 0} players matched${importMatchRate === null ? "" : ` (${importMatchRate}%)`}` + value: draftDataSignalCount === 3 ? "Complete" : draftState ? `${draftDataSignalCount}/3 sources` : "Pending", + detail: draftDataSignalCount === 3 + ? "ECR, season projections, and Sleeper ADP loaded" + : hasImportedRankings + ? "Add season projections and Sleeper ADP for full-quality advice" : isRealDraftActive ? "FantasyPros CSV required for real advice" : isDemoDraftActive ? "Demo projections active" : "Available after draft selection", - tone: hasImportedRankings || isDemoDraftActive ? (rankingImportSummary && (rankingImportSummary.unmatched.length > 0 || rankingImportSummary.ambiguous.length > 0) ? "warning" : "ready") : isRealDraftActive ? "warning" : "neutral", + tone: draftDataSignalCount === 3 || isDemoDraftActive ? "ready" : isRealDraftActive ? "warning" : "neutral", }, ]); const manageAvailable = $derived(Boolean(teamManagerState) && isRealDraftActive); @@ -1021,6 +1244,13 @@ const state = teamManagerState as TeamManagerState | null; return teamProjectionSeason || state?.league.season || seasonInput.trim() || ""; }); + const draftDataDefaultSeason = $derived.by(() => { + return selectedDraft?.season + || teamManagerState?.league.season + || connectPayload?.season + || seasonInput.trim() + || String(new Date().getFullYear()); + }); const weeklyProjectionDefaultWeek = $derived.by(() => { const state = teamManagerState as TeamManagerState | null; return teamProjectionWeek || state?.week || 1; @@ -1120,6 +1350,11 @@ }} /> + {#if workspaceMode === "draft"} @@ -1169,30 +1404,60 @@ {#if draftPhase !== "complete"} {#if (userTeam?.roster.length ?? 0) > 0} {/if} - {:else if hasImportedRankings} + {:else if draftDataSignalCount > 0} {/if} @@ -1216,6 +1481,20 @@
+ { + const params = new URLSearchParams(); + if (input.userRosterId) params.set("userRosterId", input.userRosterId); + if (input.draftId) params.set("draftId", input.draftId); + if (input.week) params.set("week", String(input.week)); + const query = params.toString(); + const response = await apiFetch(`${apiBase}/leagues/${encodeURIComponent(input.leagueId)}/rankings/ros/import${query ? `?${query}` : ""}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + source: "fantasypros", + season: input.season, + scoring: input.scoring, + csvText: input.csvText, + }), + }); + if (!response.ok) { + throw new Error(await readErrorMessage(response, "Could not import rest-of-season rankings.")); + } + return (await response.json()) as RosRankingImportPayload; +} + +export async function clearRosRankingsRequest( + leagueId: string, + season: string, + scoring: DraftScoringFormat, +): Promise<{ deleted: boolean }> { + const params = new URLSearchParams({ season, scoring }); + const response = await apiFetch(`${apiBase}/leagues/${encodeURIComponent(leagueId)}/rankings/ros?${params.toString()}`, { + method: "DELETE", + }); + if (!response.ok) { + throw new Error(await readErrorMessage(response, "Could not clear rest-of-season rankings.")); + } + return (await response.json()) as { deleted: boolean }; +} + export async function importWeeklyProjectionsRequest(input: { leagueId: string; season: string; @@ -250,11 +295,12 @@ export async function importRankingsRequest( draftId: string, userRosterId: string | null, csvText: string, + scoring: string, ): Promise { const response = await apiFetch(buildDraftUrl(draftId, "rankings/import", userRosterId), { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ source: "fantasypros", csvText }), + body: JSON.stringify({ source: "fantasypros", scoring, csvText }), }); if (!response.ok) { @@ -264,6 +310,74 @@ export async function importRankingsRequest( return (await response.json()) as RankingImportPayload; } +export async function importSeasonProjectionsRequest(input: { + draftId: string; + userRosterId: string | null; + season: string; + files: Array<{ position: Position; csvText: string }>; +}): Promise { + const response = await apiFetch(buildDraftUrl(input.draftId, "projections/season/import", input.userRosterId), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + source: "fantasypros", + season: input.season, + files: input.files, + }), + }); + if (!response.ok) { + throw new Error(await readErrorMessage(response, "Season projection import failed.")); + } + return (await response.json()) as SeasonProjectionImportPayload; +} + +export async function clearSeasonProjectionsRequest( + draftId: string, + userRosterId: string | null, +): Promise { + const response = await apiFetch(buildDraftUrl(draftId, "projections/season/import", userRosterId), { + method: "DELETE", + }); + if (!response.ok) { + throw new Error(await readErrorMessage(response, "Could not clear season projections.")); + } + return (await response.json()) as DraftPayload; +} + +export async function importAdpRequest(input: { + draftId: string; + userRosterId: string | null; + season: string; + csvText: string; +}): Promise { + const response = await apiFetch(buildDraftUrl(input.draftId, "adp/import", input.userRosterId), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + source: "fantasypros", + season: input.season, + csvText: input.csvText, + }), + }); + if (!response.ok) { + throw new Error(await readErrorMessage(response, "Sleeper ADP import failed.")); + } + return (await response.json()) as AdpImportPayload; +} + +export async function clearAdpRequest( + draftId: string, + userRosterId: string | null, +): Promise { + const response = await apiFetch(buildDraftUrl(draftId, "adp/import", userRosterId), { + method: "DELETE", + }); + if (!response.ok) { + throw new Error(await readErrorMessage(response, "Could not clear Sleeper ADP.")); + } + return (await response.json()) as DraftPayload; +} + export async function clearRankingsRequest(draftId: string, userRosterId: string | null): Promise { const response = await apiFetch(buildDraftUrl(draftId, "rankings/import", userRosterId), { method: "DELETE", diff --git a/apps/web/src/lib/components/DataManagementPanel.svelte b/apps/web/src/lib/components/DataManagementPanel.svelte index 9acab39..9c838df 100644 --- a/apps/web/src/lib/components/DataManagementPanel.svelte +++ b/apps/web/src/lib/components/DataManagementPanel.svelte @@ -17,6 +17,9 @@ const rows: Array<{ category: LocalDataCategory; label: string; description: string; count: keyof StorageInventory }> = [ { category: "rankings", label: "Draft rankings", description: "FantasyPros ranking imports saved by draft.", count: "rankingImports" }, + { category: "season-projections", label: "Season projections", description: "FantasyPros season projections saved by draft.", count: "seasonProjectionImports" }, + { category: "adp", label: "Draft ADP", description: "FantasyPros Sleeper ADP imports saved by draft.", count: "adpImports" }, + { category: "ros-rankings", label: "ROS rankings", description: "FantasyPros rest-of-season rankings saved by league and season.", count: "rosRankingImports" }, { category: "weekly-projections", label: "Weekly projections", description: "FantasyPros projection imports saved by league and week.", count: "weeklyProjectionImports" }, { category: "decision-history", label: "Decision history", description: "Local recommendation snapshots used for review.", count: "decisionSnapshots" }, ]; diff --git a/apps/web/src/lib/components/FormatCompatibilityNotice.svelte b/apps/web/src/lib/components/FormatCompatibilityNotice.svelte new file mode 100644 index 0000000..9e22903 --- /dev/null +++ b/apps/web/src/lib/components/FormatCompatibilityNotice.svelte @@ -0,0 +1,49 @@ + + +{#if compatibility && compatibility.level !== "supported" && compatibility.warnings.length > 0} + +{/if} + + diff --git a/apps/web/src/lib/components/RankingsImportPanel.svelte b/apps/web/src/lib/components/RankingsImportPanel.svelte index b130c90..f4c4fc7 100644 --- a/apps/web/src/lib/components/RankingsImportPanel.svelte +++ b/apps/web/src/lib/components/RankingsImportPanel.svelte @@ -1,199 +1,293 @@ -
+
-
- {#if expanded} - - {/if} - {#if expanded && hasImportedRankings} - - {/if} - - {hasImportedRankings ? "Imported" : hasDraft ? "Ready to import" : "After draft selection"} - -
+ + {loadedCount === 3 ? "Complete" : hasDraft ? `${loadedCount}/3 loaded` : "After draft selection"} +
{#if expanded} -
-
- Add reliable player values -

- Download the FantasyPros rankings CSV for this scoring format, then upload it here. The import is saved to this draft and replaces low-confidence Sleeper search ranks. -

-
- -
- - - - {#if !hasDraft} -

Select a Sleeper draft before importing rankings.

+

Combine expert opinion, projected production, and Sleeper draft behavior without treating them as the same signal.

+ +
+
+ 1 +
+ {scoring || "Scoring-aware"} ECR + Expert ranks and tiers +
+ + {rankingImportSummary ? `${rankingImportSummary.matched} matched` : "Required"} + +
+
+ + + + {#if rankingImportSummary} + + {/if} +
+ {#if rankingImportSummary} + {rankingImportSummary.scoring ?? scoring} - imported {formatImportDate(rankingImportSummary)} {/if} + {#if rankingImportError}

{rankingImportError}

{/if}
-
- {#if rankingImportSummary} -

- Active import: FantasyPros rankings from {formatImportDate(rankingImportSummary)}. Saved for this draft and - reapplied automatically. -

-
-
- {rankingImportSummary.matched} - matched -
-
- {rankingImportSummary.rowsParsed} - rows parsed -
-
- {rankingImportSummary.unmatched.length} - unmatched +
+
+ 2 +
+ {season} season projections + League-scored points and replacement value +
+ + {seasonProjectionImportSummary ? `${seasonProjectionImportSummary.matched} matched` : "Recommended"} +
-
- {rankingImportSummary.ambiguous.length} - ambiguous +
+ + + + {#if seasonProjectionImportSummary} + + {/if}
+ {#if projectionFiles.length > 0} +
+ {#each projectionFiles as file}{file.position}{/each} +
+ {/if} + {#if seasonProjectionImportSummary} + + {seasonProjectionImportSummary.positions.join(", ")} - imported {formatImportDate(seasonProjectionImportSummary)} + + {/if} + {#if selectionError}

{selectionError}

{/if} + {#if seasonProjectionImportError}

{seasonProjectionImportError}

{/if} + {#if seasonProjectionImportSummary?.warnings.length} +

{seasonProjectionImportSummary.warnings[0]}

+ {/if}
- {#if rankingImportSummary.unmatched.length > 0 || rankingImportSummary.ambiguous.length > 0} -
- {#if rankingImportSummary.unmatched.length > 0} -
- Unmatched rows -
    - {#each rankingImportSummary.unmatched.slice(0, 12) as row} -
  • {row.row}: {row.name} {row.team ? `(${row.team})` : ""}
  • - {/each} -
-
- {/if} - - {#if rankingImportSummary.ambiguous.length > 0} -
- Ambiguous rows -
    - {#each rankingImportSummary.ambiguous.slice(0, 12) as row} -
  • {row.row}: {row.name} matched multiple players: {row.candidates.join(", ")}
  • - {/each} -
-
+
+
+ 3 +
+ Sleeper ADP + Market value and return probability +
+ + {adpImportSummary ? `${adpImportSummary.matched} matched` : "Recommended"} + +
+
+ + + + {#if adpImportSummary} + {/if}
- {/if} - {/if} - - {#if rankingImportError} -

{rankingImportError}

- {/if} + {#if adpImportSummary} + + Sleeper{adpImportSummary.includesRealTime ? " + Real-Time" : ""} - imported {formatImportDate(adpImportSummary)} + + {/if} + {#if adpImportError}

{adpImportError}

{/if} +
{/if}
diff --git a/apps/web/src/lib/components/RosRankingsImportPanel.svelte b/apps/web/src/lib/components/RosRankingsImportPanel.svelte new file mode 100644 index 0000000..96449bd --- /dev/null +++ b/apps/web/src/lib/components/RosRankingsImportPanel.svelte @@ -0,0 +1,172 @@ + + +
+
+
+

Team data

+

Rest-of-season rankings

+ + {summary ? `${summary.matched} matched - ${summary.season} ${summary.scoring}` : "Long-term waiver and roster value"} + +
+ + {hasTeam ? `${sourceCount}/2 sources` : "After team load"} + +
+ +

+ Weekly projections answer who helps now. Overall ROS ECR protects long-term add, drop, and stash decisions. +

+ + {#if mismatch && summary} +

+ + Saved rankings are for {summary.season}; this league is {leagueSeason}. They remain stored but do not affect advice. +

+ {/if} + +
+ +
+ Scoring +
{scoring}
+
+
+ +
+ + + {#if summary} + + {/if} +
+ + + + {#if summary} +

+ {summary.matched}/{summary.rowsParsed} matched, saved {formatImportDate(summary)}. + {#if summary.unmatched.length > 0 || summary.ambiguous.length > 0} + Review: {summary.unmatched.length} unmatched, {summary.ambiguous.length} ambiguous. + {/if} +

+ {/if} + + {#if error} +

{error}

+ {/if} +
+ + diff --git a/apps/web/src/lib/components/WeeklyProjectionsImportPanel.svelte b/apps/web/src/lib/components/WeeklyProjectionsImportPanel.svelte index c23da64..d3d8359 100644 --- a/apps/web/src/lib/components/WeeklyProjectionsImportPanel.svelte +++ b/apps/web/src/lib/components/WeeklyProjectionsImportPanel.svelte @@ -20,6 +20,7 @@ leagueSeason, currentWeek, summary, + rosLoaded, error, isImporting, isClearing, @@ -34,6 +35,7 @@ leagueSeason: string; currentWeek: number; summary: WeeklyProjectionImportSummary | null; + rosLoaded: boolean; error: string; isImporting: boolean; isClearing: boolean; @@ -69,6 +71,7 @@ ? "Not imported yet" : "Available after team load", ); + const sourceCount = $derived(Number(Boolean(summary)) + Number(rosLoaded)); const hasImportMismatch = $derived(Boolean(summary && leagueSeason && summary.season !== leagueSeason)); const viewingDifferentWeek = $derived(Boolean(currentWeek && week !== currentWeek)); const totalUnmatched = $derived(summary?.positionResults.reduce((total, result) => total + result.unmatched, 0) ?? 0); @@ -129,8 +132,8 @@

Weekly projections

{statusText}
- - {summary ? "Imported" : hasTeam ? "Optional import" : "After team load"} + + {hasTeam ? `${sourceCount}/2 sources` : "After team load"} diff --git a/apps/web/src/lib/format.ts b/apps/web/src/lib/format.ts index 149806e..821f0e4 100644 --- a/apps/web/src/lib/format.ts +++ b/apps/web/src/lib/format.ts @@ -1,4 +1,4 @@ -import type { CandidateSignal, ConnectDraft, DraftState, Player, RankingImportSummary, WeeklyProjectionImportSummary } from "./types"; +import type { CandidateSignal, ConnectDraft, DraftState, Player } from "./types"; export function isMockDraft(draftId: string) { return draftId === "mock" || draftId === "mock-draft"; @@ -34,7 +34,7 @@ export function sourceLabel(candidate: CandidateSignal) { return candidate.player.importedSource ? `${candidate.player.importedSource} import` : "Imported data"; } -export function formatImportDate(summary: RankingImportSummary | WeeklyProjectionImportSummary) { +export function formatImportDate(summary: { appliedAt: string }) { return new Date(summary.appliedAt).toLocaleString([], { month: "short", day: "numeric", diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts index 3059fa3..c847347 100644 --- a/apps/web/src/lib/types.ts +++ b/apps/web/src/lib/types.ts @@ -1,15 +1,25 @@ -import type { AppSettings, CandidateSignal, DraftRecommendation, DraftState, Player, Position, RankingImportSummary, TeamActivitySummary, TeamDataReadiness, TeamLineupSummary, TeamManagerState, TeamNeedsSummary, TeamWaiverSummary, TeamWeekContext, TeamWeekPlayer, WeeklyProjectionImportSummary } from "@sleeper-draft-assistant/shared"; +import type { AdpImportSummary, AppSettings, CandidateSignal, DraftRecommendation, DraftScoringFormat, DraftState, FormatCompatibility, Player, Position, RankingImportSummary, RosRankingImportSummary, SeasonProjectionImportSummary, TeamActivitySummary, TeamDataReadiness, TeamLineupSummary, TeamManagerState, TeamNeedsSummary, TeamWaiverSummary, TeamWeekContext, TeamWeekPlayer, WeeklyProjectionImportSummary } from "@sleeper-draft-assistant/shared"; export type DraftPayload = { state: DraftState; recommendation: DraftRecommendation; rankingImportSummary: RankingImportSummary | null; + seasonProjectionImportSummary: SeasonProjectionImportSummary | null; + adpImportSummary: AdpImportSummary | null; }; export type RankingImportPayload = DraftPayload & { summary: RankingImportSummary; }; +export type SeasonProjectionImportPayload = DraftPayload & { + summary: SeasonProjectionImportSummary; +}; + +export type AdpImportPayload = DraftPayload & { + summary: AdpImportSummary; +}; + export type ConnectDraft = { draftId: string; name: string; @@ -73,6 +83,7 @@ export type TeamPayload = { weekContext: TeamWeekContext | null; waiverSummary: TeamWaiverSummary; activitySummary: TeamActivitySummary; + rosRankingSummary: RosRankingImportSummary | null; weeklyProjectionSummary: WeeklyProjectionImportSummary | null; }; export type TeamAskAnswerPayload = TeamPayload & { @@ -81,6 +92,9 @@ export type TeamAskAnswerPayload = TeamPayload & { export type WeeklyProjectionImportPayload = TeamPayload & { summary: WeeklyProjectionImportSummary; }; +export type RosRankingImportPayload = TeamPayload & { + summary: RosRankingImportSummary; +}; export type WeeklyProjectionStatusPayload = { summary: WeeklyProjectionImportSummary | null; @@ -108,7 +122,7 @@ export type AiProviderStatus = { detail?: string; }; -export type { AppSettings, CandidateSignal, DraftRecommendation, DraftState, Player, Position, RankingImportSummary, TeamActivitySummary, TeamDataReadiness, TeamLineupSummary, TeamManagerState, TeamNeedsSummary, TeamWaiverSummary, TeamWeekContext, TeamWeekPlayer, WeeklyProjectionImportSummary }; +export type { AdpImportSummary, AppSettings, CandidateSignal, DraftRecommendation, DraftScoringFormat, DraftState, FormatCompatibility, Player, Position, RankingImportSummary, RosRankingImportSummary, SeasonProjectionImportSummary, TeamActivitySummary, TeamDataReadiness, TeamLineupSummary, TeamManagerState, TeamNeedsSummary, TeamWaiverSummary, TeamWeekContext, TeamWeekPlayer, WeeklyProjectionImportSummary }; @@ -138,6 +152,9 @@ export type DiagnosticsPayload = { sqliteStorage: boolean; settingsRecords: number; rankingImportRecords: number; + seasonProjectionImportRecords: number; + adpImportRecords: number; + rosRankingImportRecords: number; weeklyProjectionImportRecords: number; decisionSnapshots: number; }; @@ -153,11 +170,14 @@ export type StorageInventory = { location: "application-data" | "development-data"; sqliteStorage: true; rankingImports: number; + seasonProjectionImports: number; + adpImports: number; + rosRankingImports: number; weeklyProjectionImports: number; decisionSnapshots: number; }; -export type LocalDataCategory = "rankings" | "weekly-projections" | "decision-history"; +export type LocalDataCategory = "rankings" | "season-projections" | "adp" | "ros-rankings" | "weekly-projections" | "decision-history"; export type DataMutationPayload = { deleted: number; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ce6f36c..350f67d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -38,21 +38,26 @@ Electron runs with `contextIsolation: true`, `nodeIntegration: false`, and `sand 1. The user identifies a Sleeper account, league, or draft. 2. The API reads tokenless data from Sleeper and normalizes it into shared models. -3. A user-supplied FantasyPros CSV may add rankings, tiers, bye weeks, and value signals. The repository does not ship ranking data. -4. User-supplied weekly projection files for QB, RB, WR, TE, K, and DST may add week-specific points to lineup and waiver analysis. -5. Weekly projections are scoped to a league, season, and week. Stored historical or mismatched data is visible but cannot influence current advice. -6. The deterministic engine evaluates import coverage and matching quality before assigning lineup confidence. -7. Complete weekly lineups expose current-versus-optimized totals and per-swap point deltas; incomplete data remains explicitly partial. -8. While Team Manager is visible, the renderer refreshes its existing read-only aggregate every 60 seconds and when the app regains focus. Transient refresh failures preserve the last successful team state. -9. Optional AI providers receive a compact context packet rather than the full player universe. -10. Settings, imports, and decision snapshots are persisted locally in `app.sqlite`. -11. Authenticated data-management routes expose aggregate counts, category deletion, a typed-confirmation reset, and a support report that reduces decision history to non-identifying event metadata. +3. Normalization produces one format-compatibility assessment for draft and team workflows. Conventional standard, half-PPR, PPR, FLEX, and superflex leagues are supported; custom scoring and TE premium produce cautions; IDP and auction formats are marked unsupported. +4. A user-supplied FantasyPros draft rankings CSV adds scoring-specific ECR, tiers, bye weeks, and expert-versus-market context. Known scoring mismatches are rejected. +5. User-supplied FantasyPros season projection files for QB, RB, WR, TE, K, and DST are normalized and, where the export exposes the required statistics, rescored with the connected Sleeper league settings. K and DST retain provider points when exact league scoring cannot be reproduced. +6. A user-supplied FantasyPros Overall ADP CSV adds Sleeper ADP and Real-Time ADP. Sleeper ADP drives platform-specific value; the more aggressive of Sleeper and Real-Time ADP informs pick-return risk. +7. These draft signals remain separate in the model and persistence layer. The repository does not ship ranking, projection, or ADP data. +8. A user-supplied FantasyPros overall rest-of-season rankings CSV adds scoring-specific ECR and expert disagreement to Team Manager. One overall export is used instead of separate position exports, and known scoring mismatches are rejected. +9. User-supplied weekly projection files add provider-scored week-specific points to lineup and waiver analysis. Weekly points drive immediate lineup ordering; rest-of-season ECR informs longer-term add, drop, and stash value. +10. Rest-of-season rankings are scoped to a league, season, and scoring format. Weekly projections are scoped to a league, season, and week. Stored historical or mismatched data cannot influence current advice. +11. The deterministic engine evaluates import coverage, matching quality, and format compatibility before assigning confidence. +12. Complete weekly lineups expose current-versus-optimized totals and per-swap point deltas; incomplete data remains explicitly partial. +13. While Team Manager is visible, the renderer refreshes its existing read-only aggregate every 60 seconds and when the app regains focus. Transient refresh failures preserve the last successful team state. +14. Optional AI providers receive a compact context packet rather than the full player universe. +15. Settings, imports, and decision snapshots are persisted locally in `app.sqlite`. +16. Authenticated data-management routes expose aggregate counts, category deletion, a typed-confirmation reset, and a support report that reduces decision history to non-identifying event metadata. ## Persistence Packaged runs use Electron's per-user data directory. Development defaults to repository `data/` or `SLEEPER_AI_DATA_DIR` when supplied. Writes use atomic replacement, reject symlink components observed during path validation, and use owner-only POSIX permissions. The path checks are defense in depth, not a race-proof boundary against another process running as the same OS user. Windows privacy depends on the user's profile ACLs. -The renderer can clear ranking imports, weekly projections, or decision history independently. A full reset also restores provider settings and removes app-owned renderer preferences before reload. Exported support reports omit database contents, local paths, identifiers, names, recommendation text, imported values, and provider credentials. +The renderer can clear draft rankings, season projections, draft ADP, rest-of-season rankings, weekly projections, or decision history independently. A full reset also restores provider settings and removes app-owned renderer preferences before reload. Exported support reports omit database contents, local paths, identifiers, names, recommendation text, imported values, and provider credentials. ## Provider boundary @@ -68,5 +73,8 @@ Executable configuration is limited to commands or paths ending in `codex`, `cod - Persistence uses `sql.js`, so the database is exported after mutations rather than managed by a native SQLite service. - The API is local single-user software; it is not designed for LAN or multi-user hosting. - Signed installers are not implemented. -- Weekly projections require user-downloaded CSV exports; there is no automatic projection feed. +- Draft, rest-of-season, and weekly values require user-downloaded CSV exports; there is no automatic FantasyPros feed. +- IDP player values, auction budgets, and nomination strategy are not modeled. +- Custom scoring and TE-premium accuracy depends on importing values produced for the same format. +- Exact kicker and defense rescoring is unavailable when exports omit field-goal distance and points-allowed distributions. - The capability token is not an IPC replacement and does not defend against same-user malware. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index e43940f..347a362 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -14,6 +14,7 @@ This is a direction document, not a promise or release schedule. - roster, lineup, waiver, week, and activity views - optional local Codex app-server analysis - local SQLite persistence and Electron packaging +- explicit format compatibility warnings for custom scoring, TE premium, IDP, and auction leagues ## Before the first published alpha installer @@ -31,6 +32,7 @@ This is a direction document, not a promise or release schedule. - stronger accessibility and keyboard-navigation coverage - release signing when sustainable - more deterministic engine fixtures and explanation tests +- dedicated dynasty, keeper, auction, and IDP models if demand justifies their separate complexity ## Non-goals for now diff --git a/docs/WORKFLOW.md b/docs/WORKFLOW.md index cbd7f38..51f1a32 100644 --- a/docs/WORKFLOW.md +++ b/docs/WORKFLOW.md @@ -65,6 +65,13 @@ Delete the disposable directory afterward. Never paste production tokens into sh - **Web requests return 401:** use the root `npm run dev` launcher so renderer and API receive the same token. - **Codex cannot start:** verify the configured executable is `codex`, `codex.exe`, or `codex.cmd`, then run `codex login` separately. - **Windows package output is locked:** close all Sleeper Draft Assistant processes and retry. -- **Recommendations warn about placeholder values:** import a current rankings CSV for the league's scoring format. +- **Recommendations warn about placeholder values:** import the scoring-specific ECR CSV first, then season projections and Overall ADP for full draft context. +- **League format warning:** standard, half-PPR, PPR, FLEX, and superflex are supported. Custom scoring and TE premium require matching imports; IDP and auction advice is unsupported. +- **Ranking import is rejected:** choose the FantasyPros ECR or ROS export matching the connected league's standard, half-PPR, or PPR scoring. +- **No separate Real-Time ADP download:** the Overall ADP export already contains both `Sleeper` and `Real-Time` columns. +- **Season projection import skips FLX:** import QB, RB, WR, TE, K, and DST; FLX duplicates the individual skill-position exports. +- **Team Manager needs two data sources:** import one scoring-specific overall rest-of-season rankings CSV for long-term value, then the six weekly projection files for current-week points. +- **Rest-of-season rankings are inactive:** confirm the import season and scoring format match the connected league. Historical imports remain stored but do not affect current advice. - **Weekly advice has no projection data:** import all six FantasyPros position exports for the selected season and week in Team Manager. +- **Weekly scoring warning:** weekly `FPTS` are used as FantasyPros provides them. Confirm the FantasyPros export scoring matches the Sleeper league. - **Historical projections are inactive:** confirm the selected season and week match the connected league before evaluating lineup or waiver advice. diff --git a/package-lock.json b/package-lock.json index 07f219c..d04e8ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sleeper-draft-assistant", - "version": "0.1.0-alpha.3", + "version": "0.1.0-alpha.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sleeper-draft-assistant", - "version": "0.1.0-alpha.3", + "version": "0.1.0-alpha.4", "license": "MIT", "workspaces": [ "apps/*", @@ -24,11 +24,11 @@ }, "apps/api": { "name": "@sleeper-draft-assistant/api", - "version": "0.1.0-alpha.3", + "version": "0.1.0-alpha.4", "dependencies": { "@hono/node-server": "^2.0.12", - "@sleeper-draft-assistant/engine": "0.1.0-alpha.3", - "@sleeper-draft-assistant/shared": "0.1.0-alpha.3", + "@sleeper-draft-assistant/engine": "0.1.0-alpha.4", + "@sleeper-draft-assistant/shared": "0.1.0-alpha.4", "hono": "^4.10.7", "sql.js": "^1.14.1" }, @@ -38,7 +38,7 @@ }, "apps/desktop": { "name": "@sleeper-draft-assistant/desktop", - "version": "0.1.0-alpha.3", + "version": "0.1.0-alpha.4", "devDependencies": { "electron": "43.0.0", "electron-builder": "26.15.3", @@ -47,10 +47,10 @@ }, "apps/web": { "name": "@sleeper-draft-assistant/web", - "version": "0.1.0-alpha.3", + "version": "0.1.0-alpha.4", "dependencies": { "@fontsource/inter": "^5.2.8", - "@sleeper-draft-assistant/shared": "0.1.0-alpha.3", + "@sleeper-draft-assistant/shared": "0.1.0-alpha.4", "@sveltejs/vite-plugin-svelte": "^6.2.1", "svelte": "^5.43.14", "vite": "^7.2.7" @@ -5504,9 +5504,9 @@ }, "packages/engine": { "name": "@sleeper-draft-assistant/engine", - "version": "0.1.0-alpha.3", + "version": "0.1.0-alpha.4", "dependencies": { - "@sleeper-draft-assistant/shared": "0.1.0-alpha.3" + "@sleeper-draft-assistant/shared": "0.1.0-alpha.4" }, "devDependencies": { "vitest": "^3.2.4" @@ -5514,7 +5514,7 @@ }, "packages/shared": { "name": "@sleeper-draft-assistant/shared", - "version": "0.1.0-alpha.3", + "version": "0.1.0-alpha.4", "dependencies": { "zod": "^3.25.76" } diff --git a/package.json b/package.json index 811694d..243751c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sleeper-draft-assistant", - "version": "0.1.0-alpha.3", + "version": "0.1.0-alpha.4", "private": true, "type": "module", "workspaces": [ diff --git a/packages/engine/package.json b/packages/engine/package.json index 8a4d17e..64b4e81 100644 --- a/packages/engine/package.json +++ b/packages/engine/package.json @@ -1,6 +1,6 @@ { "name": "@sleeper-draft-assistant/engine", - "version": "0.1.0-alpha.3", + "version": "0.1.0-alpha.4", "private": true, "type": "module", "exports": { @@ -13,7 +13,7 @@ "test": "vitest run" }, "dependencies": { - "@sleeper-draft-assistant/shared": "0.1.0-alpha.3" + "@sleeper-draft-assistant/shared": "0.1.0-alpha.4" }, "devDependencies": { "vitest": "^3.2.4" diff --git a/packages/engine/src/index.test.ts b/packages/engine/src/index.test.ts index d2598a0..509f613 100644 --- a/packages/engine/src/index.test.ts +++ b/packages/engine/src/index.test.ts @@ -160,6 +160,71 @@ describe("mock draft engine", () => { expect(recommendation.candidates[0]?.reasons).toContain("matches RB/WR flex demand"); }); + it("caps recommendation confidence when league settings need caution", () => { + const state = createEightTeamTwoFlexState(); + state.settings.formatCompatibility = { + level: "caution", + features: ["te_premium"], + warnings: ["TE-premium values require a matching import."], + }; + state.players = state.players.map((player) => ({ + ...player, + projectionSource: "season_projection", + })); + + const recommendation = buildDraftRecommendation(state); + + expect(recommendation.confidence).not.toBe("high"); + expect(recommendation.risks).toContain("TE-premium values require a matching import."); + }); + + it("does not let imported season signals saturate a shallow one-QB board", () => { + const state = createEightTeamTwoFlexState(); + const importedRanks = new Map([ + ["format-qb-allen", 25], + ["format-rb-gibbs", 4], + ["format-wr-chase", 1], + ["format-rb-bijan", 3], + ["format-te-bowers", 16], + ["format-qb-hurts", 31], + ]); + state.players = state.players.map((player) => ({ + ...player, + projectionSource: "season_projection", + importedRank: importedRanks.get(player.id), + seasonProjectedPoints: player.projectedPoints, + seasonProjectionSource: "FantasyPros", + seasonProjectionSeason: "2026", + seasonProjectionCoverage: "league_scored", + adpSource: "FantasyPros Sleeper ADP", + })); + + const recommendation = buildDraftRecommendation(state); + const allen = recommendation.candidates.find((candidate) => candidate.player.id === "format-qb-allen"); + + expect(recommendation.candidates[0]?.player.position).toMatch(/RB|WR/); + expect(recommendation.recommendedPlayerId).not.toBe("format-qb-allen"); + expect(recommendation.candidates[0]?.score).toBeLessThan(100); + expect(allen?.score).toBeLessThan(recommendation.candidates[0]?.score ?? 0); + }); + + it("uses the faster of Sleeper and real-time ADP for return probability", () => { + const state = createEightTeamTwoFlexState(); + const player = state.players.find((candidate) => candidate.id === "format-rb-gibbs"); + if (!player) { + throw new Error("Expected Gibbs fixture."); + } + player.adp = 28; + player.realTimeAdp = 4; + player.adpSource = "FantasyPros Sleeper ADP"; + + const signal = buildCandidateSignals(state, state.players.length) + .find((candidate) => candidate.player.id === player.id); + + expect(signal?.returnProbability).toBe(0.08); + expect(signal?.reasons).toContain("real-time market is 24.0 picks earlier than Sleeper ADP"); + }); + it("explains shallow-league QB replacement pressure in small one-QB formats", () => { const state = createEightTeamTwoFlexState(); const signals = buildCandidateSignals(state, 10); @@ -370,6 +435,37 @@ describe("team needs engine", () => { expect(summary.candidates[0]?.player.id).toBe("fa-weekly"); expect(summary.candidates[0]?.valueLabel).toBe("24.0 weekly pts"); }); + it("balances weekly projections with rest-of-season value for waivers", () => { + const state = createTeamManagerState(); + const streamer = { + ...teamPlayer("fa-streamer", "One Week Streamer", "RB", 18, 180), + ...weeklyProjectionFields(18), + rosRank: 200, + rosAverageRank: 200, + rosBestRank: 170, + rosWorstRank: 230, + rosSource: "FantasyPros", + rosSeason: "2026", + rosScoring: "PPR" as const, + }; + const longTerm = { + ...teamPlayer("fa-long-term", "Long Term Starter", "RB", 16, 20), + ...weeklyProjectionFields(16), + rosRank: 20, + rosAverageRank: 20, + rosBestRank: 15, + rosWorstRank: 26, + rosSource: "FantasyPros", + rosSeason: "2026", + rosScoring: "PPR" as const, + }; + + const summary = buildTeamWaiverSummary(state, [streamer, longTerm]); + + expect(summary.candidates[0]?.player.id).toBe("fa-long-term"); + expect(summary.candidates[0]?.valueLabel).toBe("16.0 weekly pts - ROS 20"); + expect(summary.candidates[0]?.reasons.some((reason) => reason.includes("expert range 15-26"))).toBe(true); + }); it("summarizes available add and drop candidates", () => { const state = createTeamManagerState({ bench: [teamPlayer("bench-k", "Bench K", "K", 100, 260)], diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index b446a05..7f6ed39 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -16,7 +16,7 @@ import type { WeeklyProjectionImportSummary, } from "@sleeper-draft-assistant/shared"; -const positionBaselines: Record = { +const defaultPositionBaselines: Record = { QB: 250, RB: 180, WR: 175, @@ -120,6 +120,7 @@ export function buildTeamDataReadiness( warnings.push("No weekly projection import is loaded for this team view."); } else { facts.push(`FantasyPros ${weeklyProjectionSummary.season} Week ${weeklyProjectionSummary.week} was imported ${weeklyProjectionSummary.appliedAt}.`); + warnings.push("Weekly FPTS are provider-scored; confirm the FantasyPros export uses this league's scoring format."); if (state.league.season && weeklyProjectionSummary.season !== state.league.season) { warnings.push(`Imported projections are for ${weeklyProjectionSummary.season}, but this league is ${state.league.season}.`); } @@ -247,9 +248,14 @@ function buildLineupLimitations(rosterPlayers: Player[]): string[] { function buildWaiverLimitations(players: Player[]): string[] { const hasWeeklyProjections = players.some((player) => player.projectionSource === "weekly_projection"); + const hasRosRankings = players.some((player) => player.rosRank !== null && player.rosRank !== undefined); return [ - hasWeeklyProjections - ? "Waiver candidates use imported weekly projections when matched, plus Sleeper availability and roster metadata." + hasWeeklyProjections && hasRosRankings + ? "Waiver candidates combine imported weekly projections with rest-of-season ECR, Sleeper availability, and roster needs." + : hasWeeklyProjections + ? "Waiver candidates use imported weekly projections when matched, plus Sleeper availability and roster metadata." + : hasRosRankings + ? "Waiver candidates use rest-of-season ECR, Sleeper availability, and roster needs without current-week projections." : "Waiver candidates use available Sleeper player metadata and imported rankings when present, not real-time projections or news.", "Availability is inferred from players not currently present on Sleeper league rosters.", "Drop suggestions are deterministic roster-shape signals and should be reviewed before making real moves.", @@ -323,6 +329,8 @@ function buildLineupDecision(slot: TeamManagerState["roster"]["starters"][number if (recommendedPlayer?.projectionSource === "weekly_projection" && recommendedPlayer.weeklyProjectedPoints !== null && recommendedPlayer.weeklyProjectedPoints !== undefined) { reasons.push(`Recommended option has FantasyPros Week ${recommendedPlayer.weeklyProjectionWeek ?? "?"} projection of ${recommendedPlayer.weeklyProjectedPoints.toFixed(1)} points.`); + } else if (recommendedPlayer?.rosRank) { + reasons.push(`Recommended option has FantasyPros ROS rank ${recommendedPlayer.rosRank}.`); } else if (recommendedPlayer?.importedRank) { reasons.push(`Recommended option has imported rank ${recommendedPlayer.importedRank}${recommendedPlayer.tier ? `, tier ${recommendedPlayer.tier}` : ""}.`); } @@ -434,7 +442,7 @@ function getPlayerSignalConfidence(player: Player): TeamLineupSummary["confidenc if (getWeeklyProjectedPoints(player) !== null) { return "high"; } - if (player.projectionSource === "imported" || player.importedRank) { + if (player.rosRank || player.projectionSource === "imported" || player.importedRank) { return "medium"; } return "low"; @@ -458,9 +466,11 @@ function buildWaiverCandidate(player: Player, teamNeeds: TeamNeedsSummary, roste const beatsRosterBest = rosterBestAtPosition ? comparePlayersForLineup(player, rosterBestAtPosition) < 0 : true; const reasons: string[] = []; let rosterFit: TeamWaiverSummary["candidates"][number]["rosterFit"] = "stash"; - let score = player.projectionSource === "weekly_projection" && player.weeklyProjectedPoints !== null && player.weeklyProjectedPoints !== undefined - ? player.weeklyProjectedPoints * 12 - : Math.max(0, 260 - rank); + const weeklyPoints = getWeeklyProjectedPoints(player); + const longTermValue = Math.max(0, 260 - rank); + let score = weeklyPoints !== null + ? weeklyPoints * 10 + longTermValue * 0.45 + : longTermValue; if (need?.status === "open_starter") { rosterFit = "starter_need"; @@ -478,11 +488,17 @@ function buildWaiverCandidate(player: Player, teamNeeds: TeamNeedsSummary, roste reasons.push(`Profiles as a bench stash at ${player.position}.`); } - if (player.projectionSource === "weekly_projection" && player.weeklyProjectedPoints !== null && player.weeklyProjectedPoints !== undefined) { - reasons.push(`FantasyPros Week ${player.weeklyProjectionWeek ?? "?"} projection: ${player.weeklyProjectedPoints.toFixed(1)} points.`); - } else if (player.importedRank) { + if (weeklyPoints !== null) { + reasons.push(`FantasyPros Week ${player.weeklyProjectionWeek ?? "?"} projection: ${weeklyPoints.toFixed(1)} points.`); + } + if (player.rosRank) { + const range = player.rosBestRank && player.rosWorstRank + ? ` (expert range ${player.rosBestRank}-${player.rosWorstRank})` + : ""; + reasons.push(`FantasyPros ${player.rosScoring ?? ""} ROS rank ${player.rosRank}${range}.`); + } else if (!weeklyPoints && player.importedRank) { reasons.push(`FantasyPros import rank ${player.importedRank}${player.tier ? `, tier ${player.tier}` : ""}.`); - } else { + } else if (!weeklyPoints) { reasons.push("No imported rank matched; using Sleeper metadata fallback."); } @@ -503,9 +519,13 @@ function buildWaiverCandidate(player: Player, teamNeeds: TeamNeedsSummary, roste player, score: Math.round(score * 10) / 10, rosterFit, - valueLabel: player.projectionSource === "weekly_projection" && player.weeklyProjectedPoints !== null && player.weeklyProjectedPoints !== undefined - ? `${player.weeklyProjectedPoints.toFixed(1)} weekly pts` - : player.importedRank ? `Rank ${player.importedRank}` : "Sleeper fallback", + valueLabel: weeklyPoints !== null && player.rosRank + ? `${weeklyPoints.toFixed(1)} weekly pts - ROS ${player.rosRank}` + : weeklyPoints !== null + ? `${weeklyPoints.toFixed(1)} weekly pts` + : player.rosRank + ? `ROS rank ${player.rosRank}` + : player.importedRank ? `Rank ${player.importedRank}` : "Sleeper fallback", suggestedDrop, reasons, }; @@ -533,6 +553,9 @@ function buildDropCandidates(state: TeamManagerState, rosterPlayers: Player[]): score += 15; reasons.push(`Risk tags: ${player.riskTags.join(", ")}.`); } + if (player.rosRank) { + reasons.push(`FantasyPros ROS rank ${player.rosRank}.`); + } if (reasons.length === 0) { reasons.push("Bench player with the weakest deterministic value signal."); } @@ -563,7 +586,11 @@ function buildWaiverFacts( } function getPlayerRank(player: Player): number { - return player.importedRank ?? player.adp ?? Math.max(1, Math.round(400 - player.projectedPoints)); + return player.rosAverageRank + ?? player.rosRank + ?? player.importedRank + ?? player.adp + ?? Math.max(1, Math.round(400 - player.projectedPoints)); } function isFantasyStarterPosition(position: Position): boolean { @@ -610,8 +637,8 @@ function takeBestEligiblePlayer(remaining: Map, eligiblePosition } function comparePlayersForLineup(a: Player, b: Player): number { - const aRank = a.importedRank ?? a.adp ?? Number.MAX_SAFE_INTEGER; - const bRank = b.importedRank ?? b.adp ?? Number.MAX_SAFE_INTEGER; + const aRank = getPlayerRank(a); + const bRank = getPlayerRank(b); const aHasWeeklyProjection = a.projectionSource === "weekly_projection" && a.weeklyProjectedPoints !== null && a.weeklyProjectedPoints !== undefined; const bHasWeeklyProjection = b.projectionSource === "weekly_projection" && b.weeklyProjectedPoints !== null && b.weeklyProjectedPoints !== undefined; if (aHasWeeklyProjection && bHasWeeklyProjection && a.weeklyProjectedPoints !== b.weeklyProjectedPoints) { @@ -806,9 +833,10 @@ export function buildCandidateSignals(state: DraftState, limit = 8, options: Dra const rosterCounts = countRosterPositions(userTeam, state.players); const preferenceSets = toPreferenceSets(options.preferences); const available = getAvailablePlayers(state).filter((player) => !preferenceSets.excluded.has(player.id)); + const replacementBaselines = getReplacementBaselines(state); return available - .map((player) => toCandidateSignal(player, state, rosterCounts, preferenceSets)) + .map((player) => toCandidateSignal(player, state, rosterCounts, preferenceSets, replacementBaselines)) .sort((a, b) => b.score - a.score) .slice(0, limit); } @@ -835,14 +863,21 @@ export function buildDraftRecommendation(state: DraftState, options: DraftRecomm .join(" or "); const isPlaceholder = top.player.projectionSource === "sleeper_search_rank"; + const formatLevel = state.settings.formatCompatibility?.level ?? "supported"; + const signalConfidence = isPlaceholder ? "low" : top.score > 80 ? "high" : top.score > 66 ? "medium" : "low"; + const confidence = formatLevel === "unsupported" + ? "low" + : formatLevel === "caution" && signalConfidence === "high" + ? "medium" + : signalConfidence; return { headline: isPlaceholder ? `Placeholder lean: ${top.player.name}` : `Lean ${top.player.name}`, recommendedPlayerId: top.player.id, - confidence: isPlaceholder ? "low" : top.score > 80 ? "high" : top.score > 66 ? "medium" : "low", + confidence, candidates, summary: getRecommendationSummary(top, alternatives), - risks: collectRisks(top), + risks: [...collectRisks(top), ...(state.settings.formatCompatibility?.warnings ?? [])], assumptions: [...getRecommendationAssumptions(top.player.projectionSource), ...getPreferenceAssumptions(state, options.preferences)], }; } @@ -879,6 +914,13 @@ function getRecommendationAssumptions(source: Player["projectionSource"]): strin ]; } + if (source === "season_projection") { + return [ + "Imported season projections are scored with the connected Sleeper league settings for supported offensive statistics.", + "Kicker and defense projections may use FantasyPros provider points when the export lacks enough detail for exact league scoring.", + ]; + } + return [ "Imported projections or rankings are powering this recommendation.", ]; @@ -905,21 +947,39 @@ function toCandidateSignal( state: DraftState, rosterCounts: Record, preferences: NormalizedRecommendationPreferences, + replacementBaselines: Record, ): CandidateSignal { - const baseline = positionBaselines[player.position]; + const baseline = replacementBaselines[player.position]; const projectedEdge = Number((player.projectedPoints - baseline).toFixed(1)); const rosterFit = getRosterFit(player.position, rosterCounts); - const scarcityBoost = getScarcityBoost(player.position, state); + const scarcityBoost = getScarcityBoost(player.position, state, baseline); const constructionBoost = getRosterConstructionBoost(player.position, state, rosterCounts); const adpValue = player.adp === null ? 0 : player.adp - state.currentPick; const returnProbability = estimateReturnProbability(player, state); - const fitBoost = rosterFit === "need" ? 13 : rosterFit === "depth" ? 5 : -4; + const fitBoost = rosterFit === "need" ? 9 : rosterFit === "depth" ? 4 : -4; const tierBoost = player.tier === null ? 0 : Math.max(0, 8 - player.tier); - const importedRankBoost = player.importedRank ? Math.max(0, 30 - player.importedRank / 16) : 0; + const importedRankBoost = player.importedRank + ? clamp(12 - player.importedRank / 4, -8, 12) + : 0; const riskPenalty = player.riskTags.length * 3; const preferenceBoost = getPreferenceBoost(player.id, preferences); + const projectionBoost = clamp( + projectedEdge / getProjectedEdgeDivisor(player.position), + -12, + 25, + ); + const marketBoost = clamp(adpValue / 8, -10, 10); const score = clamp( - 55 + projectedEdge / getProjectedEdgeDivisor(player.position) + fitBoost + scarcityBoost + constructionBoost + adpValue / 6 + tierBoost + importedRankBoost + preferenceBoost - riskPenalty, + 35 + + projectionBoost + + fitBoost + + scarcityBoost + + clamp(constructionBoost, -10, 12) + + marketBoost + + tierBoost + + importedRankBoost + + preferenceBoost - + riskPenalty, 0, 100, ); @@ -985,10 +1045,10 @@ function getRosterConstructionBoost( return -10; } -function getScarcityBoost(position: Position, state: DraftState): number { +function getScarcityBoost(position: Position, state: DraftState, baseline: number): number { const availableAtPosition = getAvailablePlayers(state).filter((player) => player.position === position); const aboveBaseline = availableAtPosition.filter( - (player) => player.projectedPoints > positionBaselines[position], + (player) => player.projectedPoints > baseline, ).length; if (position === "TE" && aboveBaseline <= 2) { @@ -1003,7 +1063,12 @@ function getScarcityBoost(position: Position, state: DraftState): number { } function estimateReturnProbability(player: Player, state: DraftState): number { - const marketRank = player.adp ?? player.importedRank ?? null; + const currentMarketRanks = [player.adp, player.realTimeAdp].filter( + (rank): rank is number => rank !== null && rank !== undefined, + ); + const marketRank = currentMarketRanks.length > 0 + ? Math.min(...currentMarketRanks) + : player.importedRank ?? null; if (marketRank === null) { return 0.35; } @@ -1030,8 +1095,14 @@ function estimateReturnProbability(player: Player, state: DraftState): number { function picksUntilNextUserPick(state: DraftState): number { const userSlot = state.teams.find((team) => team.id === state.userTeamId)?.draftSlot ?? 1; const currentPick = state.currentPick; - - for (let pickNo = currentPick; pickNo <= state.settings.teams * state.settings.rounds; pickNo += 1) { + const currentRound = Math.ceil(currentPick / state.settings.teams); + const currentPickInRound = ((currentPick - 1) % state.settings.teams) + 1; + const currentDraftSlot = currentRound % 2 === 1 + ? currentPickInRound + : state.settings.teams + 1 - currentPickInRound; + const searchFrom = currentDraftSlot === userSlot ? currentPick + 1 : currentPick; + + for (let pickNo = searchFrom; pickNo <= state.settings.teams * state.settings.rounds; pickNo += 1) { const round = Math.ceil(pickNo / state.settings.teams); const pickInRound = ((pickNo - 1) % state.settings.teams) + 1; const draftSlot = round % 2 === 1 ? pickInRound : state.settings.teams + 1 - pickInRound; @@ -1086,6 +1157,19 @@ function getValueLabel(player: Player, adpValue: number): string { return player.importedRank ? `rank ${player.importedRank}` : "imported rank"; } + if (player.adpSource) { + if (adpValue >= 12) { + return "major Sleeper ADP discount"; + } + if (adpValue >= 4) { + return "positive Sleeper ADP value"; + } + if (adpValue <= -10) { + return "ahead of Sleeper ADP"; + } + return "near Sleeper ADP"; + } + if (adpValue >= 12) { return "major ADP discount"; } @@ -1100,6 +1184,39 @@ function getValueLabel(player: Player, adpValue: number): string { return "near market"; } + +function getReplacementBaselines(state: DraftState): Record { + if (!state.players.some((player) => player.projectionSource === "season_projection")) { + return defaultPositionBaselines; + } + + const slots = state.settings.rosterSlots; + const flexSlots = (slots.FLEX ?? 0) + (slots.WR_RB_FLEX ?? 0); + const receivingFlexSlots = slots.REC_FLEX ?? 0; + const superFlexSlots = (slots.SUPER_FLEX ?? 0) + (slots.SF ?? 0); + const demandPerTeam: Record = { + QB: (slots.QB ?? 0) + superFlexSlots, + RB: (slots.RB ?? 0) + flexSlots * 0.45, + WR: (slots.WR ?? 0) + flexSlots * 0.45 + receivingFlexSlots * 0.75, + TE: (slots.TE ?? 0) + flexSlots * 0.1 + receivingFlexSlots * 0.25, + K: slots.K ?? 0, + DEF: slots.DEF ?? 0, + }; + + return Object.fromEntries( + (Object.keys(defaultPositionBaselines) as Position[]).map((position) => { + const projections = state.players + .filter((player) => player.position === position && player.projectionSource === "season_projection") + .map((player) => player.projectedPoints) + .sort((a, b) => b - a); + const replacementIndex = Math.max(0, Math.ceil(state.settings.teams * demandPerTeam[position]) - 1); + return [ + position, + projections[replacementIndex] ?? projections.at(-1) ?? defaultPositionBaselines[position], + ]; + }), + ) as Record; +} function getScarcityLabel(boost: number): string { if (boost >= 8) { return "thin tier"; @@ -1147,6 +1264,17 @@ function getReasons( reasons.push("unlikely to return to your next pick"); } + if ( + player.adp !== null && + player.adp !== undefined && + player.realTimeAdp !== null && + player.realTimeAdp !== undefined && + Math.abs(player.adp - player.realTimeAdp) >= 8 + ) { + const direction = player.realTimeAdp < player.adp ? "earlier" : "later"; + reasons.push(`real-time market is ${Math.abs(player.adp - player.realTimeAdp).toFixed(1)} picks ${direction} than Sleeper ADP`); + } + if (player.riskTags.length > 0) { reasons.push(`risk flags: ${player.riskTags.join(", ")}`); } @@ -1164,6 +1292,11 @@ function getPrimarySignalReason(player: Player, projectedEdge: number): string { return `${player.weeklyProjectionSource ?? "Imported"} Week ${player.weeklyProjectionWeek ?? "?"} projection: ${player.weeklyProjectedPoints.toFixed(1)} points`; } + if (player.projectionSource === "season_projection" && player.seasonProjectedPoints !== null && player.seasonProjectedPoints !== undefined) { + const qualifier = player.seasonProjectionCoverage === "provider_approximation" ? "provider estimate" : "league-scored"; + return `${player.seasonProjectionSource ?? "Imported"} ${player.seasonProjectionSeason ?? "season"} projection: ${player.seasonProjectedPoints.toFixed(1)} points (${qualifier})`; + } + if (player.projectionSource === "imported" && player.importedRank) { const tier = player.tier ? `, tier ${player.tier}` : ""; return `${player.importedSource ?? "Imported"} rank ${player.importedRank}${tier}`; diff --git a/packages/shared/package.json b/packages/shared/package.json index 7367aac..3a2e7c2 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "name": "@sleeper-draft-assistant/shared", - "version": "0.1.0-alpha.3", + "version": "0.1.0-alpha.4", "private": true, "type": "module", "exports": { diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 07dbf87..a781ce8 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -3,9 +3,19 @@ import { z } from "zod"; export const PositionSchema = z.enum(["QB", "RB", "WR", "TE", "K", "DEF"]); export type Position = z.infer; -export const PlayerSignalSourceSchema = z.enum(["mock", "sleeper_search_rank", "imported", "weekly_projection"]); +export const PlayerSignalSourceSchema = z.enum(["mock", "sleeper_search_rank", "imported", "season_projection", "weekly_projection"]); export type PlayerSignalSource = z.infer; +export const DraftScoringFormatSchema = z.enum(["PPR", "Half PPR", "Standard", "Custom", "Unknown"]); +export type DraftScoringFormat = z.infer; + +export const FormatCompatibilitySchema = z.object({ + level: z.enum(["supported", "caution", "unsupported"]), + features: z.array(z.enum(["superflex", "te_premium", "custom_scoring", "idp", "auction"])), + warnings: z.array(z.string()), +}); +export type FormatCompatibility = z.infer; + export const PlayerSchema = z.object({ id: z.string(), sleeperId: z.string(), @@ -22,10 +32,25 @@ export const PlayerSchema = z.object({ importedSource: z.string().nullable().optional(), byeWeek: z.number().nullable().optional(), ecrVsAdp: z.number().nullable().optional(), + seasonProjectedPoints: z.number().nullable().optional(), + seasonProjectionSource: z.string().nullable().optional(), + seasonProjectionSeason: z.string().nullable().optional(), + seasonProjectionCoverage: z.enum(["league_scored", "provider_approximation"]).nullable().optional(), + adpSource: z.string().nullable().optional(), + realTimeAdp: z.number().nullable().optional(), weeklyProjectedPoints: z.number().nullable().optional(), weeklyProjectionSource: z.string().nullable().optional(), weeklyProjectionSeason: z.string().nullable().optional(), weeklyProjectionWeek: z.number().nullable().optional(), + rosRank: z.number().nullable().optional(), + rosPositionRank: z.number().nullable().optional(), + rosBestRank: z.number().nullable().optional(), + rosWorstRank: z.number().nullable().optional(), + rosAverageRank: z.number().nullable().optional(), + rosStdDev: z.number().nullable().optional(), + rosSource: z.string().nullable().optional(), + rosSeason: z.string().nullable().optional(), + rosScoring: DraftScoringFormatSchema.optional(), }); export type Player = z.infer; @@ -34,6 +59,7 @@ export type RankingImportSource = z.infer; export const RankingImportRequestSchema = z.object({ source: RankingImportSourceSchema.default("fantasypros"), + scoring: DraftScoringFormatSchema.default("Unknown"), csvText: z.string().min(1), }); export type RankingImportRequest = z.infer; @@ -80,10 +106,109 @@ export const RankingImportSummarySchema = z.object({ candidates: z.array(z.string()), }), ), + scoring: DraftScoringFormatSchema.optional(), appliedAt: z.string(), }); export type RankingImportSummary = z.infer; +export const SeasonProjectionImportRequestSchema = z.object({ + source: z.literal("fantasypros").default("fantasypros"), + season: z.string().trim().min(4), + files: z.array(z.object({ + position: PositionSchema, + csvText: z.string().min(1), + })).min(1).max(6), +}); +export type SeasonProjectionImportRequest = z.infer; + +export const SeasonProjectionImportSummarySchema = z.object({ + source: z.literal("fantasypros"), + season: z.string(), + scoring: z.string(), + positions: z.array(PositionSchema), + rowsParsed: z.number(), + matched: z.number(), + unmatched: z.array(z.object({ + row: z.number(), + name: z.string(), + team: z.string().nullable(), + position: PositionSchema.nullable(), + })), + ambiguous: z.array(z.object({ + row: z.number(), + name: z.string(), + team: z.string().nullable(), + position: PositionSchema.nullable(), + candidates: z.array(z.string()), + })), + approximatePositions: z.array(PositionSchema), + warnings: z.array(z.string()), + appliedAt: z.string(), +}); +export type SeasonProjectionImportSummary = z.infer; + +export const AdpImportRequestSchema = z.object({ + source: z.literal("fantasypros").default("fantasypros"), + season: z.string().trim().min(4), + csvText: z.string().min(1), +}); +export type AdpImportRequest = z.infer; + +export const AdpImportSummarySchema = z.object({ + source: z.literal("fantasypros"), + market: z.literal("Sleeper"), + season: z.string(), + rowsParsed: z.number(), + matched: z.number(), + unmatched: z.array(z.object({ + row: z.number(), + name: z.string(), + team: z.string().nullable(), + position: PositionSchema.nullable(), + })), + ambiguous: z.array(z.object({ + row: z.number(), + name: z.string(), + team: z.string().nullable(), + position: PositionSchema.nullable(), + candidates: z.array(z.string()), + })), + includesRealTime: z.boolean(), + appliedAt: z.string(), +}); +export type AdpImportSummary = z.infer; + +export const RosRankingImportRequestSchema = z.object({ + source: z.literal("fantasypros").default("fantasypros"), + season: z.string().trim().min(4), + scoring: DraftScoringFormatSchema, + csvText: z.string().min(1), +}); +export type RosRankingImportRequest = z.infer; + +export const RosRankingImportSummarySchema = z.object({ + source: z.literal("fantasypros"), + season: z.string(), + scoring: DraftScoringFormatSchema, + rowsParsed: z.number(), + matched: z.number(), + unmatched: z.array(z.object({ + row: z.number(), + name: z.string(), + team: z.string().nullable(), + position: PositionSchema.nullable(), + })), + ambiguous: z.array(z.object({ + row: z.number(), + name: z.string(), + team: z.string().nullable(), + position: PositionSchema.nullable(), + candidates: z.array(z.string()), + })), + appliedAt: z.string(), +}); +export type RosRankingImportSummary = z.infer; + export const WeeklyProjectionImportSourceSchema = z.enum(["fantasypros"]); export type WeeklyProjectionImportSource = z.infer; @@ -168,7 +293,9 @@ export const DraftSettingsSchema = z.object({ teams: z.number(), rounds: z.number(), scoring: z.string(), + scoringSettings: z.record(z.string(), z.number()).optional(), rosterSlots: z.record(z.string(), z.number()), + formatCompatibility: FormatCompatibilitySchema.optional(), }); export type DraftSettings = z.infer; @@ -204,6 +331,7 @@ export const TeamManagerStateSchema = z.object({ teams: z.number(), scoring: z.string(), rosterSlots: z.record(z.string(), z.number()), + formatCompatibility: FormatCompatibilitySchema.optional(), }), userTeam: z.object({ rosterId: z.string(),