+
+
+ )}
+
+ {/* Info Card */}
+
+
+
+
+ Snapshot System
+
+
+ Snapshots are automatically created daily for all active seasons.
+ Use the buttons below to manually trigger snapshot creation if
+ needed.
+
+
+
+
+
+ Automatic Snapshots: The system creates snapshots
+ once per day for all active seasons that don't already have a snapshot for today.
+
+
+ Manual Snapshots: Use the "Create Snapshot"
+ buttons below to manually trigger snapshot creation for specific
+ seasons or all active seasons.
+
+
+ 7-Day Comparison: Standings pages show movement
+ indicators comparing current rank to the snapshot from 7 days ago.
+
+
+
+
+
+ {/* Bulk Actions */}
+
+
+ Bulk Actions
+
+ Create snapshots for all active seasons at once
+
+
+
+
+
+
+
+ {/* Seasons Table */}
+
+
+ Active Seasons
+
+ {seasons.length} season(s) eligible for snapshots
+
+
+
+ {seasons.length === 0 ? (
+
Movement Indicators: Arrows show rank changes
- since the last standings update.
+ compared to 7 days ago. Green arrows (↑) indicate improvement in rank,
+ red arrows (↓) indicate decline.
Placement Breakdown: Shows how many times each
diff --git a/plans/scoring-system.md b/plans/scoring-system.md
index 6d5f7e3..2f0c7ae 100644
--- a/plans/scoring-system.md
+++ b/plans/scoring-system.md
@@ -1305,11 +1305,21 @@ scoring_events {
- app/models/__tests__/tiebreaker-logic.test.ts (new tests)
- app/components/standings/__tests__/StandingsTable.test.tsx (new tests)
-- [ ] **4.2** Movement tracking & snapshots
- - [ ] Daily snapshot creation (scheduled job)
- - [ ] 7-day comparison logic (Q16, Q12)
- - [ ] Movement indicators (up/down arrows)
- - [ ] Store snapshots for historical analysis (Q16)
+- [x] **4.2** Movement tracking & snapshots ✅ *Completed*
+ - [x] Daily snapshot creation (scheduled job)
+ - [x] 7-day comparison logic (Q16, Q12)
+ - [x] Movement indicators (up/down arrows)
+ - [x] Store snapshots for historical analysis (Q16)
+
+ **Implementation Notes**:
+ - Created `server/snapshots.ts` - automated daily snapshot system (runs once per day)
+ - System starts automatically with server initialization in `server/socket.ts`
+ - Updated `app/routes/leagues/$leagueId.standings.$seasonId.tsx` to use `getSevenDayStandingsChange()`
+ - Created admin interface at `/admin/standings-snapshots` for manual snapshot management
+ - Bulk actions available to create snapshots for all active seasons
+ - Movement indicators show green arrows for rank improvements, red arrows for declines
+ - Comprehensive test suite with 15 unit tests in `app/models/__tests__/standings-snapshots.test.ts`
+ - All 386 tests passing ✅
- [ ] **4.3** Team breakdown pages
- [ ] `TeamScoreBreakdown` component
@@ -1436,10 +1446,11 @@ scoring_events {
- **3.4** Pattern-specific UI Components - PlayoffBracket, SeasonStandings, SportSeasonDisplay components + member route
- ⏳ **Phase 4**: Standings & Display - IN PROGRESS
- ✅ **4.1** Enhanced Standings Table - COMPLETE (tiebreaker logic, placement breakdowns, standalone page, navigation)
+ - ✅ **4.2** Movement Tracking & Snapshots - COMPLETE (daily snapshots, 7-day comparison, admin interface)
- ⏳ **4.5** League Home Integration - PARTIAL (standings link added, sports season cards pending)
- ⏳ **Phase 5**: Expected Value - TODO
- ⏳ **Phase 6**: Polish & Optimization - TODO
-**Current Focus**: Phase 4 - Standings & Display (4.1 complete with standalone page, 4.5 partially complete)
+**Current Focus**: Phase 4 - Standings & Display (4.1 and 4.2 complete, 4.5 partially complete)
-**Total Test Count**: 371 tests passing ✅
+**Total Test Count**: 386 tests passing ✅
diff --git a/server/snapshots.ts b/server/snapshots.ts
new file mode 100644
index 0000000..3a8b6de
--- /dev/null
+++ b/server/snapshots.ts
@@ -0,0 +1,128 @@
+import { drizzle } from "drizzle-orm/postgres-js";
+import postgres from "postgres";
+import * as schema from "~/database/schema";
+import { eq, inArray, or } from "drizzle-orm";
+import { createDailySnapshot } from "~/models/standings";
+
+// Create a dedicated database connection for the snapshot system
+const connectionString = process.env.DATABASE_URL;
+if (!connectionString) {
+ throw new Error("DATABASE_URL is required for snapshot system");
+}
+const client = postgres(connectionString);
+const db = drizzle(client, { schema });
+
+let snapshotInterval: NodeJS.Timeout | null = null;
+const CHECK_INTERVAL = 24 * 60 * 60 * 1000; // Check once per day (in milliseconds)
+
+/**
+ * Start the daily snapshot system
+ * Runs once per day to create snapshots for all active seasons
+ */
+export function startSnapshotSystem(): void {
+ if (snapshotInterval) {
+ console.log("[Snapshots] Snapshot system already running");
+ return;
+ }
+
+ // Run immediately on startup
+ void createDailySnapshots();
+
+ // Then run once per day
+ snapshotInterval = setInterval(async () => {
+ try {
+ await createDailySnapshots();
+ } catch (error) {
+ console.error("[Snapshots] Error creating daily snapshots:", error);
+ }
+ }, CHECK_INTERVAL);
+
+ console.log("[Snapshots] Daily snapshot system started (runs once per day)");
+}
+
+/**
+ * Stop the snapshot system
+ */
+export function stopSnapshotSystem(): void {
+ if (snapshotInterval) {
+ clearInterval(snapshotInterval);
+ snapshotInterval = null;
+ console.log("[Snapshots] Snapshot system stopped");
+ }
+}
+
+/**
+ * Create daily snapshots for all active seasons
+ * Only creates snapshots if they don't already exist for today
+ */
+async function createDailySnapshots(): Promise {
+ const today = new Date().toISOString().split("T")[0];
+
+ console.log(`[Snapshots] Checking for snapshots to create (${today})`);
+
+ // Get all seasons that are active or in draft (we want to track standings for these)
+ const activeSeasons = await db.query.seasons.findMany({
+ where: or(
+ eq(schema.seasons.status, "active"),
+ eq(schema.seasons.status, "draft")
+ ),
+ });
+
+ if (activeSeasons.length === 0) {
+ console.log("[Snapshots] No active seasons found");
+ return;
+ }
+
+ console.log(`[Snapshots] Found ${activeSeasons.length} active season(s)`);
+
+ for (const season of activeSeasons) {
+ try {
+ // Check if we already have a snapshot for today for any team in this season
+ const existingSnapshot = await db.query.teamStandingsSnapshots.findFirst({
+ where: eq(schema.teamStandingsSnapshots.seasonId, season.id),
+ orderBy: (snapshots, { desc }) => [desc(snapshots.snapshotDate)],
+ });
+
+ // If the most recent snapshot is from today, skip this season
+ if (existingSnapshot && existingSnapshot.snapshotDate === today) {
+ console.log(`[Snapshots] Snapshot already exists for season ${season.id} on ${today}`);
+ continue;
+ }
+
+ // Create snapshot using the standings model function
+ await createDailySnapshot(season.id, db);
+ console.log(`[Snapshots] ✅ Created snapshot for season ${season.id}`);
+ } catch (error) {
+ console.error(`[Snapshots] Error creating snapshot for season ${season.id}:`, error);
+ }
+ }
+
+ console.log("[Snapshots] Daily snapshot check complete");
+}
+
+/**
+ * Manually trigger snapshot creation for specific seasons
+ * Useful for admin tools or manual triggers
+ */
+export async function createSnapshotsForSeasons(seasonIds: string[]): Promise {
+ console.log(`[Snapshots] Manual trigger for ${seasonIds.length} season(s)`);
+
+ for (const seasonId of seasonIds) {
+ try {
+ await createDailySnapshot(seasonId, db);
+ console.log(`[Snapshots] ✅ Created snapshot for season ${seasonId}`);
+ } catch (error) {
+ console.error(`[Snapshots] Error creating snapshot for season ${seasonId}:`, error);
+ throw error;
+ }
+ }
+}
+
+/**
+ * Manually trigger snapshot creation for all active seasons
+ * Useful for testing or manual refreshes
+ */
+export async function createSnapshotsForAllSeasons(): Promise {
+ console.log("[Snapshots] Manual trigger for all active seasons");
+ await createDailySnapshots();
+}
diff --git a/server/socket.ts b/server/socket.ts
index 8d8e197..837f179 100644
--- a/server/socket.ts
+++ b/server/socket.ts
@@ -174,14 +174,21 @@ export function initializeSocketIO(httpServer: HTTPServer): SocketIOServer {
global.__socketIO = io;
console.log("Socket.IO initialized");
-
+
// Start the draft timer system (async import but don't await)
import("./timer").then(({ startDraftTimerSystem }) => {
startDraftTimerSystem();
}).catch((error) => {
console.error("Failed to start timer system:", error);
});
-
+
+ // Start the daily snapshot system
+ import("./snapshots").then(({ startSnapshotSystem }) => {
+ startSnapshotSystem();
+ }).catch((error) => {
+ console.error("Failed to start snapshot system:", error);
+ });
+
return io;
}