-
Notifications
You must be signed in to change notification settings - Fork 12
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- add game to learn other people names - leverage ts-fsrs (spaced repetition algorithm) - database should be initialized and updated with the portraitGuessGames/refresh to keep it up to date - update doc for env variables
- Loading branch information
1 parent
5eef1cf
commit e732b4c
Showing
16 changed files
with
438 additions
and
22 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
86 changes: 86 additions & 0 deletions
86
apps/backend/app/controllers/portrait_guess_game/PortraitGuessGameController.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,86 @@ | ||
import ForbiddenException from "#exceptions/forbidden_exception"; | ||
import PortraitGuessable from "#models/portait_guessable"; | ||
import PortraitGuess from "#models/portrait_guess"; | ||
import User from "#models/user"; | ||
import env from "#start/env"; | ||
import type { HttpContext } from "@adonisjs/core/http"; | ||
import { schema } from "@adonisjs/validator"; | ||
import { Rating } from "ts-fsrs"; | ||
|
||
const GradeValidationSchema = schema.create({ | ||
guessId: schema.number(), | ||
grade: schema.enum([Rating.Again, Rating.Good, Rating.Hard, Rating.Easy] as const), | ||
}); | ||
|
||
export default class PortraitGuessGameController { | ||
async index({ auth }: HttpContext) { | ||
const userId = auth.user!.id; | ||
|
||
const portraitsToGuess = await PortraitGuess.query() | ||
.preload("portraitGuessable") | ||
.where("user_id", userId) | ||
.andWhere("due", "<=", new Date()) | ||
.orderByRaw("due ASC, RAND()"); | ||
|
||
return portraitsToGuess.map((p) => p.serialize()); | ||
} | ||
|
||
async store({ auth, request }: HttpContext) { | ||
const { grade, guessId } = await request.validate({ | ||
schema: GradeValidationSchema, | ||
}); | ||
|
||
const userId = auth.user!.id; | ||
const portraitGuess = await PortraitGuess.findOrFail(guessId); | ||
if (userId !== portraitGuess.userId) { | ||
throw new ForbiddenException(); | ||
} | ||
portraitGuess.addGuess(grade); | ||
await portraitGuess.save(); | ||
} | ||
|
||
async refresh() { | ||
const existingGuessablePortraits = await PortraitGuessable.all(); | ||
|
||
const refreshPortraitCardsFetch = await fetch(env.get("PORTRAIT_CARD_FETCH_URL")); | ||
const refreshPortraitCards = (await refreshPortraitCardsFetch.json()) as { | ||
UserId: number; | ||
PicturePath: string; | ||
Firstname: string; | ||
Lastname: string; | ||
}[]; | ||
|
||
const portraitById = Map.groupBy(existingGuessablePortraits, (p) => p.id); | ||
|
||
const upToDatePortraitGuessablesPromises = refreshPortraitCards.map(async (p) => { | ||
const existingPortrait = portraitById.get(p.UserId)?.at(0); | ||
const portrait = existingPortrait ?? new PortraitGuessable(); | ||
portrait.id = p.UserId; | ||
portrait.pictureUrl = p.PicturePath; | ||
portrait.guess = `${p.Firstname} ${p.Lastname}`; | ||
await portrait.save(); | ||
return portrait; | ||
}); | ||
|
||
const upToDatePortraitGuessables = await Promise.all(upToDatePortraitGuessablesPromises); | ||
|
||
const users = await User.query().preload("portraitGuesses"); | ||
const newGuessesToCreate = users | ||
.flatMap((user) => { | ||
const userGuessByGuessableId = Map.groupBy( | ||
user.portraitGuesses, | ||
(p) => p.portraitGuessableId, | ||
); | ||
return upToDatePortraitGuessables.flatMap((portraitGuessable) => { | ||
const existingGuess = userGuessByGuessableId.get(portraitGuessable.id)?.at(0); | ||
if (!existingGuess) { | ||
const newGuess = PortraitGuess.createGuess(user.id, portraitGuessable.id); | ||
return newGuess; | ||
} | ||
}); | ||
}) | ||
.filter((e): e is PortraitGuess => e !== undefined); | ||
|
||
await PortraitGuess.createMany(newGuessesToCreate); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,24 @@ | ||
import { BaseModel, column, hasMany } from "@adonisjs/lucid/orm"; | ||
import PortraitGuess from "./portrait_guess.js"; | ||
import type { HasMany } from "@adonisjs/lucid/types/relations"; | ||
import type { DateTime } from "luxon"; | ||
|
||
export default class PortraitGuessable extends BaseModel { | ||
@column({ isPrimary: true }) | ||
declare id: number; | ||
|
||
@column() | ||
declare pictureUrl: string; | ||
|
||
@column() | ||
declare guess: string; | ||
|
||
@hasMany(() => PortraitGuess) | ||
declare portraitGuesses: HasMany<typeof PortraitGuess>; | ||
|
||
@column.dateTime({ autoCreate: true }) | ||
declare createdAt: DateTime; | ||
|
||
@column.dateTime({ autoCreate: true, autoUpdate: true }) | ||
declare updatedAt: DateTime; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,71 @@ | ||
import { BaseModel, belongsTo, column } from "@adonisjs/lucid/orm"; | ||
import { DateTime } from "luxon"; | ||
import { createEmptyCard, FSRS, type Grade, type Card } from "ts-fsrs"; | ||
import PortraitGuessable from "./portait_guessable.js"; | ||
import type { BelongsTo } from "@adonisjs/lucid/types/relations"; | ||
|
||
type CardWithNoDueDate = Omit<Card, "due" | "last_review">; | ||
|
||
const CARD_COLUMN = { | ||
prepare: (value: Card | null) => (value ? JSON.stringify(value) : null), | ||
consume: (value: string | object | null) => { | ||
if (value === null) { | ||
return null; | ||
} | ||
if (typeof value === "string") { | ||
return JSON.parse(value); | ||
} | ||
return value; | ||
}, | ||
}; | ||
|
||
export default class PortraitGuess extends BaseModel { | ||
@column({ isPrimary: true }) | ||
declare id: number; | ||
|
||
@column() | ||
declare userId: number; | ||
|
||
@column.dateTime() | ||
declare due: DateTime; | ||
|
||
@column(CARD_COLUMN) | ||
declare fsrsCard: CardWithNoDueDate; | ||
|
||
@column.dateTime({ autoCreate: true }) | ||
declare createdAt: DateTime; | ||
|
||
@column.dateTime({ autoCreate: true, autoUpdate: true }) | ||
declare updatedAt: DateTime; | ||
|
||
@column() | ||
declare portraitGuessableId: number; | ||
|
||
@belongsTo(() => PortraitGuessable) | ||
declare portraitGuessable: BelongsTo<typeof PortraitGuessable>; | ||
|
||
get card(): Card { | ||
const card: Card = { | ||
...this.fsrsCard, | ||
due: this.due.toJSDate(), | ||
}; | ||
return card; | ||
} | ||
|
||
static createGuess(userId: number, portraitGuessableId: number) { | ||
const guess = new PortraitGuess(); | ||
guess.userId = userId; | ||
guess.portraitGuessableId = portraitGuessableId; | ||
const { due, ...fsrsCard } = createEmptyCard(new Date()); | ||
guess.fsrsCard = fsrsCard; | ||
guess.due = DateTime.fromJSDate(due); | ||
return guess; | ||
} | ||
|
||
addGuess(grade: Grade) { | ||
const fsrs = new FSRS({ enable_fuzz: true, enable_short_term: true }); | ||
const { card } = fsrs.next(this.card, new Date(), grade); | ||
this.fsrsCard = card; | ||
this.due = DateTime.fromJSDate(card.due); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
19 changes: 19 additions & 0 deletions
19
apps/backend/database/migrations/1723730561129_create_portrait_guessables_table.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
import { BaseSchema } from "@adonisjs/lucid/schema"; | ||
|
||
export default class extends BaseSchema { | ||
protected tableName = "portrait_guessables"; | ||
|
||
async up() { | ||
this.schema.createTable(this.tableName, (table) => { | ||
table.integer("id").unsigned().primary(); | ||
table.string("picture_url").notNullable(); | ||
table.string("guess").notNullable(); | ||
table.timestamp("created_at", { useTz: true }); | ||
table.timestamp("updated_at", { useTz: true }); | ||
}); | ||
} | ||
|
||
async down() { | ||
this.schema.dropTable(this.tableName); | ||
} | ||
} |
21 changes: 21 additions & 0 deletions
21
apps/backend/database/migrations/1723730565896_create_portrait_guesses_table.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
import { BaseSchema } from "@adonisjs/lucid/schema"; | ||
|
||
export default class extends BaseSchema { | ||
protected tableName = "portrait_guesses"; | ||
|
||
async up() { | ||
this.schema.createTable(this.tableName, (table) => { | ||
table.increments("id").primary(); | ||
table.integer("user_id").unsigned().references("users.id"); | ||
table.integer("portrait_guessable_id").unsigned().references("portrait_guessables.id"); | ||
table.timestamp("due", { useTz: true }).notNullable(); | ||
table.json("fsrs_card").notNullable(); | ||
table.timestamp("created_at", { useTz: true }); | ||
table.timestamp("updated_at", { useTz: true }); | ||
}); | ||
} | ||
|
||
async down() { | ||
this.schema.dropTable(this.tableName); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.