Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 23 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.

Expand All @@ -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.

Expand Down
6 changes: 3 additions & 3 deletions apps/api/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand All @@ -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"
},
Expand Down
26 changes: 23 additions & 3 deletions apps/api/src/ai/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.");
Expand Down Expand Up @@ -264,6 +273,10 @@ function countRosterPositions(
}

function describePlayerValueSource(sources: Set<Player["projectionSource"]>): 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.";
}
Expand All @@ -290,6 +303,13 @@ function getDataLimitations(sources: Set<Player["projectionSource"]>): 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."];
}

Expand Down
13 changes: 13 additions & 0 deletions apps/api/src/ai/team-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`,
Expand Down Expand Up @@ -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.",
Expand All @@ -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}`)
Expand Down
4 changes: 4 additions & 0 deletions apps/api/src/ai/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export type DraftAiContext = {
dataQuality: {
playerValueSource: string;
hasImportedRankings: boolean;
hasSeasonProjections: boolean;
usesSleeperPlaceholderRanks: boolean;
limitations: string[];
};
Expand Down Expand Up @@ -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[];
Expand Down
6 changes: 6 additions & 0 deletions apps/api/src/data-management.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand All @@ -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(),
};
Expand Down
153 changes: 153 additions & 0 deletions apps/api/src/draft-value-import.test.ts
Original file line number Diff line number Diff line change
@@ -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: [],
};
}
Loading