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
5 changes: 5 additions & 0 deletions app/api/cron/league-rollover/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@
* all handled in SQL. pg_cron ('league-weekly-rollover', Mondays 00:05) is the
* primary scheduler — this route is a manual trigger + Dokploy/Vercel fallback.
*
* Missed ticks need no special handling and no _week_start argument: since
* issue #549 the rollover resolves the previous week from the data and
* finalizes every still-unfinalized week, so simply running it late is the
* catch-up path. It used to reset every student to Bronze in that situation.
*
* Secured by CRON_SECRET (Bearer token).
*/

Expand Down
7 changes: 7 additions & 0 deletions lib/database.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4793,6 +4793,7 @@ export type Database = {
mode: string
questions: Json
score: number
session_id: string
source: string
source_exercise_id: number | null
tenant_id: string
Expand All @@ -4810,6 +4811,7 @@ export type Database = {
mode?: string
questions: Json
score: number
session_id?: string
source?: string
source_exercise_id?: number | null
tenant_id: string
Expand All @@ -4827,6 +4829,7 @@ export type Database = {
mode?: string
questions?: Json
score?: number
session_id?: string
source?: string
source_exercise_id?: number | null
tenant_id?: string
Expand Down Expand Up @@ -6517,6 +6520,10 @@ export type Database = {
Returns: undefined
}
publish_scheduled_lessons: { Args: never; Returns: undefined }
redeem_store_item: {
Args: { _item_id: string; _tenant_id: string; _user_id: string }
Returns: Json
}
refresh_leaderboard_cache: { Args: never; Returns: undefined }
register_push_token: {
Args: { _device_name?: string; _platform: string; _token: string }
Expand Down
51 changes: 41 additions & 10 deletions lib/notifications/daily-digest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,15 +107,45 @@ export function localDateStr(now: Date, timezone: string): string {
}
}

/** UTC yesterday as YYYY-MM-DD — matches award_xp()'s CURRENT_DATE streak math. */
export function utcYesterday(now: Date): string {
const d = new Date(now.getTime() - 24 * 60 * 60 * 1000)
return d.toISOString().slice(0, 10)
/**
* YYYY-MM-DD for the day before `now` in the given IANA timezone.
*
* Takes the local calendar date first, then steps back one calendar day on that
* date alone. Subtracting 24h from the instant would land on the wrong date
* across a DST transition.
*/
export function localYesterday(now: Date, timezone: string): string {
const [y, m, d] = localDateStr(now, timezone).split('-').map(Number)
return new Date(Date.UTC(y, m - 1, d - 1)).toISOString().slice(0, 10)
}

/** Streak survives only if the student acts today: last activity was exactly yesterday. */
export function isStreakAtRisk(lastActivityDate: string | null, streak: number, now: Date, min: number): boolean {
return streak >= min && lastActivityDate === utcYesterday(now)
/**
* Streak survives only if the student acts today: last activity was exactly
* yesterday *in the tenant's timezone*.
*
* This compared against UTC yesterday until issue #549. Everything else about
* the send is tenant-local — `localHour` decides whether to send at all, and
* `localDateStr` keys idempotency — so at UTC-5 (Bogotá, Lima) the 20:00 nudge
* runs at 01:00 UTC the *next* UTC day, and a student who practised that same
* local afternoon was told their streak was ending hours after they had secured
* it, while the students who actually skipped the local day were silently
* skipped. It misfired whenever `nudgeHour + |utc_offset| >= 24`.
*
* Known residual: `last_activity_date` is still written by award_xp() from
* CURRENT_DATE, i.e. the UTC day, so a student whose only activity fell in the
* local evening after the UTC day rolled over is still measured against the
* wrong day. Fixing that means changing award_xp()'s day boundary, which would
* shift every existing streak — a deliberate migration, not a side effect of
* this one (issue #549 §3).
*/
export function isStreakAtRisk(
lastActivityDate: string | null,
streak: number,
now: Date,
min: number,
timezone: string
): boolean {
return streak >= min && lastActivityDate === localYesterday(now, timezone)
}

const SUMMARY_COPY = {
Expand Down Expand Up @@ -290,8 +320,8 @@ export async function runDailyDigest(admin: SupabaseClient, now: Date = new Date
kind === 'daily_digest'
? c.due_cards > 0 ||
c.goals_pending > 0 ||
isStreakAtRisk(c.last_activity_date, c.current_streak, now, DIGEST_STREAK_MIN)
: isStreakAtRisk(c.last_activity_date, c.current_streak, now, NUDGE_STREAK_MIN)
isStreakAtRisk(c.last_activity_date, c.current_streak, now, DIGEST_STREAK_MIN, settings.timezone)
: isStreakAtRisk(c.last_activity_date, c.current_streak, now, NUDGE_STREAK_MIN, settings.timezone)
)
if (recipients.length === 0) continue

Expand Down Expand Up @@ -321,7 +351,8 @@ export async function runDailyDigest(admin: SupabaseClient, now: Date = new Date
candidate.last_activity_date,
candidate.current_streak,
now,
kind === 'daily_digest' ? DIGEST_STREAK_MIN : NUDGE_STREAK_MIN
kind === 'daily_digest' ? DIGEST_STREAK_MIN : NUDGE_STREAK_MIN,
settings.timezone
)
const summary = buildSummary(
{
Expand Down
2 changes: 2 additions & 0 deletions mcp-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
"dev": "mcp-use dev",
"start": "mcp-use start",
"deploy": "mcp-use deploy",
"test": "vitest run",
"test:watch": "vitest",
"postinstall": "mcp-use generate-types || exit 0"
},
"dependencies": {
Expand Down
179 changes: 179 additions & 0 deletions mcp-server/src/tools/flashcards.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { describe, it, expect } from "vitest";
import { gradeCard, cardFromRow } from "./flashcards.js";

/**
* Characterization tests (issue #549) — these capture current behaviour of
* `cardFromRow`/`gradeCard` in mcp-server/src/tools/flashcards.ts so a later
* refactor shows up as a visible test diff. Do NOT change source behaviour
* to make these pass; if source is wrong, the test documents the bug.
*/

const NOW = new Date("2026-07-26T12:00:00.000Z");

type FsrsRow = Parameters<typeof cardFromRow>[0];

/** A row that has never been through FSRS — the SM-2/pre-migration shape. */
function freshRow(overrides: Partial<FsrsRow> = {}): FsrsRow {
return {
interval_days: 0,
repetitions: 0,
due_at: NOW.toISOString(),
last_reviewed_at: null,
stability: null,
difficulty: null,
fsrs_state: 0,
lapses: 0,
learning_steps: 0,
elapsed_days: 0,
...overrides,
};
}

/** A fully FSRS-populated row (as a migration-seeded / previously-graded row would be). */
function populatedRow(overrides: Partial<FsrsRow> = {}): FsrsRow {
return {
interval_days: 6,
repetitions: 3,
due_at: "2026-07-30T00:00:00.000Z",
last_reviewed_at: "2026-07-24T00:00:00.000Z",
stability: 12.34,
difficulty: 5.67,
fsrs_state: 2, // Review
lapses: 1,
learning_steps: 0,
elapsed_days: 6,
...overrides,
};
}

describe("cardFromRow", () => {
it("returns a fresh empty card when stability is null", () => {
const row = freshRow({ stability: null, difficulty: 5 });
const card = cardFromRow(row, NOW);
expect(card.state).toBe(0); // State.New
expect(card.stability).toBe(0);
expect(card.difficulty).toBe(0);
expect(card.reps).toBe(0);
expect(card.lapses).toBe(0);
expect(card.due).toEqual(NOW);
expect(card.last_review).toBeUndefined();
});

it("returns a fresh empty card when difficulty is null", () => {
const row = freshRow({ stability: 5, difficulty: null });
const card = cardFromRow(row, NOW);
expect(card.state).toBe(0); // State.New
expect(card.stability).toBe(0);
expect(card.difficulty).toBe(0);
expect(card.due).toEqual(NOW);
expect(card.last_review).toBeUndefined();
});

it("returns a fresh empty card when both stability and difficulty are null", () => {
const row = freshRow();
const card = cardFromRow(row, NOW);
expect(card.state).toBe(0);
expect(card.due).toEqual(NOW);
});

it("round-trips a fully populated row onto the Card", () => {
const row = populatedRow();
const card = cardFromRow(row, NOW);
expect(card.stability).toBe(row.stability);
expect(card.difficulty).toBe(row.difficulty);
expect(card.reps).toBe(row.repetitions);
expect(card.lapses).toBe(row.lapses);
expect(card.state).toBe(row.fsrs_state);
expect(card.scheduled_days).toBe(row.interval_days);
expect(card.elapsed_days).toBe(row.elapsed_days);
expect(card.learning_steps).toBe(row.learning_steps);
expect(card.due).toEqual(new Date(row.due_at));
expect(card.last_review).toEqual(new Date(row.last_reviewed_at as string));
});

it("falls back to `now` for last_review when last_reviewed_at is null", () => {
const row = populatedRow({ last_reviewed_at: null });
const card = cardFromRow(row, NOW);
expect(card.last_review).toEqual(NOW);
});
});

describe("gradeCard", () => {
const ratings = ["again", "hard", "good", "easy"] as const;

it.each(ratings)("returns the full persisted-row shape for rating '%s' on a New card", (rating) => {
const row = freshRow();
const result = gradeCard(row, rating, NOW);

expect(result).toMatchObject({
stability: expect.any(Number),
difficulty: expect.any(Number),
fsrs_state: expect.any(Number),
lapses: expect.any(Number),
learning_steps: expect.any(Number),
elapsed_days: expect.any(Number),
interval_days: expect.any(Number),
repetitions: expect.any(Number),
due_at: expect.any(String),
last_reviewed_at: expect.any(String),
});

// due_at / last_reviewed_at are ISO strings.
expect(new Date(result.due_at).toISOString()).toBe(result.due_at);
expect(new Date(result.last_reviewed_at).toISOString()).toBe(result.last_reviewed_at);
expect(result.last_reviewed_at).toBe(NOW.toISOString());
});

it("orders scheduling further out as rating improves: again < hard < good < easy", () => {
const row = freshRow();
const again = gradeCard(row, "again", NOW);
const hard = gradeCard(row, "hard", NOW);
const good = gradeCard(row, "good", NOW);
const easy = gradeCard(row, "easy", NOW);

const t = (r: ReturnType<typeof gradeCard>) => new Date(r.due_at).getTime();

expect(t(again)).toBeLessThanOrEqual(t(hard));
expect(t(hard)).toBeLessThanOrEqual(t(good));
expect(t(good)).toBeLessThan(t(easy));
});

it("rating 'again' on an established Review-state card increments lapses", () => {
const row = populatedRow({ fsrs_state: 2, lapses: 1 });
const result = gradeCard(row, "again", NOW);
expect(result.lapses).toBe(2);
});

it("BUG (#549 §4): a 'lapsed' row (repetitions/interval reset but stability/difficulty null) currently grades as a brand-new card, discarding history", () => {
// This row represents history that was reset to null stability/difficulty
// (e.g. a lapse-tracking path) while repetitions/interval_days/last_reviewed_at
// still carry prior state. Because cardFromRow() only checks
// stability===null || difficulty===null, this row is treated as a
// never-seen card: FSRS starts a brand-new learning card, and prior
// repetitions/interval history is silently discarded. Asserting today's
// (buggy) behaviour here — do NOT "fix" the source to make this pass.
const lapsedRow = freshRow({
repetitions: 0,
interval_days: 0,
last_reviewed_at: "2026-07-20T00:00:00.000Z",
stability: null,
difficulty: null,
});

const card = cardFromRow(lapsedRow, NOW);
// Discards history: comes back as a brand-new createEmptyCard(now) — the
// null-stability/difficulty check short-circuits BEFORE the
// last_reviewed_at fallback logic even runs, so last_review is left
// `undefined` (createEmptyCard's default), not `now` and not the row's
// real last_reviewed_at.
expect(card.state).toBe(0); // State.New
expect(card.reps).toBe(0);
expect(card.last_review).toBeUndefined();

const result = gradeCard(lapsedRow, "good", NOW);
// A brand-new "good" grade produces the same result whether or not the
// row had prior repetitions — because cardFromRow() throws that history away.
const freshResult = gradeCard(freshRow(), "good", NOW);
expect(result).toEqual(freshResult);
});
});
Loading
Loading