feat: add collections management and study coach features
Test / backend (push) Failing after 13s
Test / frontend (push) Successful in 25s

- Implemented collection repository with CRUD operations for user collections.
- Created routes for managing collections, including listing, creating, updating, and deleting collections.
- Added sharing functionality for exams, allowing users to share and clone exams.
- Developed a study coach service that analyzes exam results and provides study tips based on weak areas.
- Introduced frontend components for managing collections, sharing exams, and displaying study coach insights.
- Enhanced user experience with loading states and error handling in shared components.
This commit is contained in:
LaurensBartelds committed 2026-05-02 16:55:08 +02:00
1 parent 7755e77039
commit 1ec1633dd9
26 files changed
+2557 -127

No files matched your search

+128 -7
View File
@@ -8,6 +8,12 @@ export interface DbExam {
exam_json: string
created_at: number
updated_at: number
share_token: string | null
is_public: number
is_favorite: number
tags: string | null
share_mode: string | null
cloned_from_share_token: string | null
}
export interface ExamListItem {
@@ -17,14 +23,18 @@ export interface ExamListItem {
created_at: number
updated_at: number
question_count: number
share_token: string | null
is_favorite: number
tags: string
collection_id: number | null
}
export function createExam(userId: number, title: string, subtitle: string | null, examJson: string): { id: number } {
export function createExam(userId: number, title: string, subtitle: string | null, examJson: string, tagsJson: string = '[]'): { id: number } {
const db = getDb()
const result = db.prepare(`
INSERT INTO saved_exams (user_id, title, subtitle, exam_json)
VALUES (?, ?, ?, ?)
`).run(userId, title, subtitle, examJson)
INSERT INTO saved_exams (user_id, title, subtitle, exam_json, tags)
VALUES (?, ?, ?, ?, ?)
`).run(userId, title, subtitle, examJson, tagsJson)
return { id: result.lastInsertRowid as number }
}
@@ -36,16 +46,24 @@ export function getExamById(id: number, userId: number): DbExam | undefined {
export function listExamsByUser(userId: number): ExamListItem[] {
const db = getDb()
return db.prepare(`
SELECT id, title, subtitle, created_at, updated_at,
SELECT id, title, subtitle, created_at, updated_at, share_token, is_favorite, tags, collection_id,
json_array_length(json_extract(exam_json, '$.questions')) AS question_count
FROM saved_exams
WHERE user_id = ?
ORDER BY updated_at DESC
ORDER BY is_favorite DESC, updated_at DESC
`).all(userId) as ExamListItem[]
}
export function updateExam(id: number, userId: number, title: string, subtitle: string | null, examJson: string): boolean {
export function updateExam(id: number, userId: number, title: string, subtitle: string | null, examJson: string, tagsJson: string | null = null): boolean {
const db = getDb()
if (tagsJson !== null) {
const result = db.prepare(`
UPDATE saved_exams
SET title = ?, subtitle = ?, exam_json = ?, tags = ?, updated_at = unixepoch()
WHERE id = ? AND user_id = ?
`).run(title, subtitle, examJson, tagsJson, id, userId)
return result.changes > 0
}
const result = db.prepare(`
UPDATE saved_exams
SET title = ?, subtitle = ?, exam_json = ?, updated_at = unixepoch()
@@ -65,3 +83,106 @@ export function countExams(): number {
const row = db.prepare('SELECT COUNT(*) AS cnt FROM saved_exams').get() as { cnt: number }
return row.cnt
}
export function setShareToken(id: number, userId: number, token: string, mode: string = 'clone'): boolean {
const db = getDb()
const result = db.prepare(`
UPDATE saved_exams
SET share_token = ?, is_public = 1, share_mode = ?, updated_at = unixepoch()
WHERE id = ? AND user_id = ?
`).run(token, mode, id, userId)
return result.changes > 0
}
export function clearShareToken(id: number, userId: number): boolean {
const db = getDb()
const result = db.prepare(`
UPDATE saved_exams
SET share_token = NULL, is_public = 0, share_mode = 'clone', updated_at = unixepoch()
WHERE id = ? AND user_id = ?
`).run(id, userId)
return result.changes > 0
}
export function getExamByShareToken(token: string): DbExam | undefined {
const db = getDb()
return db.prepare('SELECT * FROM saved_exams WHERE share_token = ? AND is_public = 1').get(token) as DbExam | undefined
}
export function setShareMode(id: number, userId: number, mode: string): boolean {
const db = getDb()
const result = db.prepare(`
UPDATE saved_exams
SET share_mode = ?, updated_at = unixepoch()
WHERE id = ? AND user_id = ?
`).run(mode, id, userId)
return result.changes > 0
}
export function cloneExam(userId: number, sourceExam: DbExam, shareToken: string): { id: number } {
const db = getDb()
const parsed = JSON.parse(sourceExam.exam_json)
parsed.id = undefined
const result = db.prepare(`
INSERT INTO saved_exams (user_id, title, subtitle, exam_json, cloned_from_share_token)
VALUES (?, ?, ?, ?, ?)
`).run(userId, sourceExam.title + ' (copy)', sourceExam.subtitle, JSON.stringify(parsed), shareToken)
return { id: result.lastInsertRowid as number }
}
export interface ClonedExamItem {
id: number
title: string
subtitle: string | null
created_at: number
updated_at: number
question_count: number
cloned_from_share_token: string
}
export function listClonedExams(userId: number): ClonedExamItem[] {
const db = getDb()
return db.prepare(`
SELECT id, title, subtitle, created_at, updated_at, cloned_from_share_token,
json_array_length(json_extract(exam_json, '$.questions')) AS question_count
FROM saved_exams
WHERE user_id = ? AND cloned_from_share_token IS NOT NULL
ORDER BY updated_at DESC
`).all(userId) as ClonedExamItem[]
}
export function setFavorite(id: number, userId: number, isFavorite: boolean): boolean {
const db = getDb()
const result = db.prepare(`
UPDATE saved_exams
SET is_favorite = ?, updated_at = unixepoch()
WHERE id = ? AND user_id = ?
`).run(isFavorite ? 1 : 0, id, userId)
return result.changes > 0
}
export function getExamRawJson(id: number, userId: number): { title: string; exam_json: string } | undefined {
const db = getDb()
return db.prepare('SELECT title, exam_json FROM saved_exams WHERE id = ? AND user_id = ?').get(id, userId) as { title: string; exam_json: string } | undefined
}
export function duplicateOwnExam(userId: number, sourceExam: DbExam): { id: number } {
const db = getDb()
const parsed = JSON.parse(sourceExam.exam_json)
parsed.id = undefined
const result = db.prepare(`
INSERT INTO saved_exams (user_id, title, subtitle, exam_json, tags)
VALUES (?, ?, ?, ?, ?)
`).run(userId, sourceExam.title + ' (copy)', sourceExam.subtitle, JSON.stringify(parsed), sourceExam.tags ?? '[]')
return { id: result.lastInsertRowid as number }
}
export function setExamCollection(id: number, userId: number, collectionId: number | null): boolean {
const db = getDb()
const result = db.prepare(`
UPDATE saved_exams
SET collection_id = ?, updated_at = unixepoch()
WHERE id = ? AND user_id = ?
`).run(collectionId, id, userId)
return result.changes > 0
}