Polish Novya UI with brand colors, first-load skeletons, and session transition overlay.

Keep chat pinned to the latest message while AI thinks or streams, and align buttons/inputs with the logo teal/blue focus treatment.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ar.badiei
2026-09-09 01:31:24 +03:30
co-authored by Cursor
parent 4ac0c16665
commit 8016b8f45e
56 changed files with 1874 additions and 529 deletions
+145
View File
@@ -0,0 +1,145 @@
/**
* Dev helper: jump a password user to Session 11 chat (sessions 1–10 done,
* pre-test done, training/post-test NOT done) so you can test the post-test handoff.
*
* Usage:
* npx tsx scripts/jump-to-session-11.ts alireza@gmail.com
*/
import { AttemptPhase, AttemptStatus, SessionProgressStatus } from '@prisma/client';
import { prisma } from '../src/lib/prisma.ts';
import { PROGRAM_SESSION_COUNT } from '../src/modules/sessions/catalog.ts';
const emailArg = process.argv[2]?.trim().toLowerCase();
if (!emailArg) {
console.error('Usage: npx tsx scripts/jump-to-session-11.ts <email>');
process.exit(1);
}
async function main() {
const identity = await prisma.authIdentity.findFirst({
where: {
provider: 'password',
OR: [{ email: emailArg }, { providerSubject: emailArg }],
},
include: {
user: { include: { journey: true, profile: true } },
},
});
if (!identity) {
throw new Error(`No password account found for ${emailArg}`);
}
const userId = identity.userId;
const now = new Date();
const hourAgo = (n: number) => new Date(now.getTime() - n * 60 * 60 * 1000);
await prisma.$transaction(async (tx) => {
await tx.learningJourney.upsert({
where: { userId },
create: {
userId,
currentStep: 'training_in_progress',
preTestCompletedAt: hourAgo(20),
trainingStartedAt: hourAgo(19),
trainingCompletedAt: null,
postTestCompletedAt: null,
lastActivityAt: now,
},
update: {
currentStep: 'training_in_progress',
preTestCompletedAt: identity.user.journey?.preTestCompletedAt ?? hourAgo(20),
trainingStartedAt: identity.user.journey?.trainingStartedAt ?? hourAgo(19),
trainingCompletedAt: null,
postTestCompletedAt: null,
lastActivityAt: now,
},
});
const existingPre = await tx.assessmentAttempt.findFirst({
where: { userId, phase: AttemptPhase.pre, status: AttemptStatus.scored },
});
if (!existingPre) {
const preAt = hourAgo(20);
await tx.assessmentAttempt.create({
data: {
userId,
phase: AttemptPhase.pre,
status: AttemptStatus.scored,
submittedAt: preAt,
scoredAt: preAt,
},
});
}
// Clear final-session / post-test leftovers so the handoff can be retested.
await tx.sessionProgress.deleteMany({
where: { userId, sessionOrder: PROGRAM_SESSION_COUNT },
});
await tx.chatSession.deleteMany({
where: { userId, sessionOrder: PROGRAM_SESSION_COUNT },
});
await tx.assessmentAttempt.deleteMany({
where: { userId, phase: AttemptPhase.post },
});
for (let order = 1; order < PROGRAM_SESSION_COUNT; order += 1) {
const completedAt = hourAgo(PROGRAM_SESSION_COUNT - order);
await tx.sessionProgress.upsert({
where: { userId_sessionOrder: { userId, sessionOrder: order } },
create: {
userId,
sessionOrder: order,
status: SessionProgressStatus.completed,
startedAt: completedAt,
completedAt,
completionMetadata: {
reason: 'Dev jump-to-session-11 helper',
sessionComplete: true,
},
},
update: {
status: SessionProgressStatus.completed,
startedAt: completedAt,
completedAt,
completionMetadata: {
reason: 'Dev jump-to-session-11 helper',
sessionComplete: true,
},
},
});
}
});
const progress = await prisma.sessionProgress.findMany({
where: { userId, status: SessionProgressStatus.completed },
select: { sessionOrder: true },
orderBy: { sessionOrder: 'asc' },
});
const journey = await prisma.learningJourney.findUnique({ where: { userId } });
console.log(
JSON.stringify(
{
email: identity.email ?? identity.providerSubject,
profileName: identity.user.profile?.name ?? null,
completedSessions: progress.map((p) => p.sessionOrder),
nextSession: PROGRAM_SESSION_COUNT,
trainingCompletedAt: journey?.trainingCompletedAt ?? null,
postTestCompletedAt: journey?.postTestCompletedAt ?? null,
hint: 'Login → open Chat. You should land on Session 11 review. Finish the wrap-up to reach post-test.',
},
null,
2,
),
);
}
main()
.catch((err) => {
console.error(err instanceof Error ? err.message : err);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
+50
View File
@@ -0,0 +1,50 @@
/**
* Dev helper: reset password for a password-auth user.
* Usage: npx tsx scripts/reset-user-password.ts <email> <newPassword>
*/
import { prisma } from '../src/lib/prisma.ts';
import { hashPassword } from '../src/modules/auth/password.ts';
const email = process.argv[2]?.trim().toLowerCase();
const nextPassword = process.argv[3];
if (!email || !nextPassword) {
console.error('Usage: npx tsx scripts/reset-user-password.ts <email> <newPassword>');
process.exit(1);
}
if (nextPassword.length < 8 || nextPassword.length > 128) {
console.error('Password must be 8–128 characters');
process.exit(1);
}
const identity = await prisma.authIdentity.findFirst({
where: {
provider: 'password',
OR: [{ email }, { providerSubject: email }],
},
});
if (!identity) {
console.error(`No password account found for ${email}`);
process.exit(1);
}
const passwordHash = await hashPassword(nextPassword);
await prisma.authIdentity.update({
where: { id: identity.id },
data: { passwordHash },
});
console.log(
JSON.stringify(
{
email: identity.email ?? identity.providerSubject,
passwordUpdated: true,
},
null,
2,
),
);
await prisma.$disconnect();
@@ -0,0 +1,74 @@
import { buildAiCoachSessionContext } from '../src/modules/ai/sessionContext.ts';
import { getSessionByOrder } from '../src/modules/sessions/catalog.ts';
import {
processCoachAssistantResponse,
looksLikeFinalPostTestHandoff,
COACH_SESSION_COMPLETE_OPEN,
COACH_SESSION_COMPLETE_CLOSE,
} from '../src/modules/ai/sessionCompletion.ts';
const s11 = getSessionByOrder(11);
if (!s11) throw new Error('no s11');
const ctx = buildAiCoachSessionContext(s11);
const samples = [
'دوره آموزشی شما تمام شد. حالا پس‌آزمون را در اپ انجام دهید.',
'جلسات تموم شد؛ نوبت پس‌آزمون است.',
'مرور تمام شد. لطفا پس‌آزمون را انجام دهید.',
'فقط یک جمله معمولی بدون اشاره.',
];
let failed = 0;
for (const t of samples) {
const handoff = looksLikeFinalPostTestHandoff(t);
const expect = t !== samples[3];
if (handoff !== expect) {
failed += 1;
console.error('handoff mismatch', { t, handoff, expect });
}
}
const text = 'مرور جلسات تمام شد. حالا پس‌آزمون را در برنامه انجام دهید.';
const r = processCoachAssistantResponse(text, ctx, { userTurnCount: 4, alreadyCompleted: false });
if (!r.validation?.valid || r.signal?.sessionOrder !== 11 || r.signal?.readyForNextSession !== false) {
failed += 1;
console.error('synthetic completion failed', r.validation, r.signal);
}
const badSignal = {
sessionComplete: true,
sessionId: 'wrong',
sessionOrder: 11,
reason: 'x',
coveredTopics: ['نامرتبط'],
completedExercises: [],
userConfirmed: true,
readyForNextSession: true,
};
// Without handoff text, a bad marker must NOT finish the course.
const badNoHandoff = processCoachAssistantResponse(
`ادامه مرور\n${COACH_SESSION_COMPLETE_OPEN}\n${JSON.stringify(badSignal)}\n${COACH_SESSION_COMPLETE_CLOSE}`,
ctx,
{ userTurnCount: 3, alreadyCompleted: false },
);
if (badNoHandoff.validation?.valid) {
failed += 1;
console.error('bad marker without handoff should not validate', badNoHandoff.validation);
}
const bad = processCoachAssistantResponse(
`دوره تمام شد. حالا پس‌آزمون را در برنامه انجام دهید.\n${COACH_SESSION_COMPLETE_OPEN}\n${JSON.stringify(badSignal)}\n${COACH_SESSION_COMPLETE_CLOSE}`,
ctx,
{ userTurnCount: 3, alreadyCompleted: false },
);
if (!bad.validation?.valid || bad.signal?.sessionId !== ctx.sessionId || bad.signal?.readyForNextSession !== false) {
failed += 1;
console.error('repair with handoff failed', bad.validation, bad.signal);
}
if (failed > 0) {
console.error(`FAILED ${failed}`);
process.exit(1);
}
console.log('session-11 completion recovery OK');