Skip to content

Commit

Permalink
feat(api + front): add spaced repetition http service, add new db fie…
Browse files Browse the repository at this point in the history
…ld, add timezone parsing
  • Loading branch information
hwgilbert16 committed Apr 16, 2024
1 parent 4d9985b commit 5d49e34
Show file tree
Hide file tree
Showing 8 changed files with 192 additions and 2 deletions.
106 changes: 106 additions & 0 deletions apps/front/src/app/shared/http/spaced-repetition.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { Injectable } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import { ApiResponse, ApiResponseOptions, SpacedRepetitionSet } from "@scholarsome/shared";
import { lastValueFrom } from "rxjs";

@Injectable({
providedIn: "root"
})
export class SpacedRepetitionService {
constructor(private readonly http: HttpClient) {}

/**
* Gets a spaced repetition set
*
* @param setId The ID of the set corresponding to the spaced repetition set
*
* @returns `SpacedRepetitionSet` object
*/
async spacedRepetitionSet(setId: string): Promise<SpacedRepetitionSet | null> {
let spacedRepetitionSet: ApiResponse<SpacedRepetitionSet> | undefined;

try {
spacedRepetitionSet = await lastValueFrom(this.http.get<ApiResponse<SpacedRepetitionSet>>("/api/spaced-repetition/sets/" + setId));
} catch (e) {
return null;
}

if (spacedRepetitionSet.status === ApiResponseOptions.Success) {
return spacedRepetitionSet.data;
} else return null;
}

/**
* Creates a spaced repetition set
*
* @param setId The ID of the set corresponding to the spaced repetition set
*
* @returns Created `Folder` object
*/
async createSpacedRepetitionSets(setId: string): Promise<SpacedRepetitionSet | null> {
let spacedRepetitionSet: ApiResponse<SpacedRepetitionSet> | undefined;

try {
spacedRepetitionSet = await lastValueFrom(this.http.post<ApiResponse<SpacedRepetitionSet>>("/api/spaced-repetition/sets/" + setId, {}));
} catch (e) {
return null;
}

if (spacedRepetitionSet.status === ApiResponseOptions.Success) {
return spacedRepetitionSet.data;
} else return null;
}

/**
* Updates a spaced repetition set
*
* @param body.id ID of the folder to be updated
* @param body.title Optional, title of the folder
* @param body.description Optional, description of the folder
* @param body.private Optional, whether the folder should be publicly visible
* @param body.sets Optional, array of the sets that should be within the folder
*
* @returns Updated `Folder` object
*/
async updateFolder(body: {
id: string;
cardsPerDay?: number;
answerWith?: "TERM" | "DEFINITION"
}): Promise<SpacedRepetitionSet | null> {
let spacedRepetitionSet: ApiResponse<SpacedRepetitionSet> | undefined;

try {
spacedRepetitionSet = await lastValueFrom(this.http.patch<ApiResponse<SpacedRepetitionSet>>("/api/spaced-repetition/sets/" + body.id, {
cardsPerDay: body.cardsPerDay,
answerWith: body.answerWith
}));
} catch (e) {
return null;
}

if (spacedRepetitionSet.status === ApiResponseOptions.Success) {
return spacedRepetitionSet.data;
} else return null;
}

/**
* Deletes a spaced repetition set
*
* @param setId The ID of the set corresponding to the spaced repetition set
*
* @returns Deleted `SpacedRepetitionSet` object
*/
async deleteSpacedRepetitionSet(setId: string): Promise<SpacedRepetitionSet | null> {
let spacedRepetitionSet: ApiResponse<SpacedRepetitionSet> | undefined;

try {
spacedRepetitionSet = await lastValueFrom(this.http.delete<ApiResponse<SpacedRepetitionSet>>("/api/spaced-repetition/sets/" + setId));
} catch (e) {
return null;
}

if (spacedRepetitionSet.status === ApiResponseOptions.Success) {
return spacedRepetitionSet.data;
} else return null;
}
}
10 changes: 10 additions & 0 deletions apps/front/src/app/shared/shared.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { HttpClient } from "@angular/common/http";
import { lastValueFrom, Subject } from "rxjs";
// eslint-disable-next-line @nx/enforce-module-boundaries
import packageJson from "../../../../../package.json";
import { DateTime } from "luxon";

@Injectable({
providedIn: "root"
Expand Down Expand Up @@ -31,4 +32,13 @@ export class SharedService {
async getStargazers(): Promise<number> {
return (await this.starsRes)["stargazers_count"];
}

convertUtcStringToTimeZone(utcDateString: string, timeZone: string): DateTime | null {
try {
const utcDateTime = DateTime.fromISO(utcDateString, { zone: "utc" });
return utcDateTime.setZone(timeZone);
} catch (error) {
return null;
}
}
}
39 changes: 39 additions & 0 deletions apps/front/src/app/study-set/study-set.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,45 @@ <h1 *ngIf="set">{{this.set.title}}</h1>
<p class="text-secondary text-wrap text-break">{{set.description}}</p>
</div>
<hr>
<div class="mt-4 d-flex justify-content-between" *ngIf="set">
<div class="ms-2 w-100" *ngIf="loggedIn">
<h5>Spaced Repetition <span class="badge bg-primary text-white fw-normal">Beta</span></h5>
<div *ngIf="spacedRepetitionStarted">
<div class="container-fluid text-center mt-4">
<div class="d-flex justify-content-evenly row row-cols-1 row-cols-md-2">
<div class="col-md-3 bg-white shadow rounded">
<div class="mt-2 fs-4">Cards Due Today</div>
<p class="text-info fs-3 fw-bold">0</p>
</div>
<div class="col-md-3 bg-white shadow rounded mt-md-0 mt-4">
<div class="mt-2 fs-4">New Cards Today</div>
<p class="text-success fs-3 fw-bold">0</p>
</div>
<div class="col-md-3 bg-white shadow rounded mt-md-0 mt-4">
<div class="mt-2 fs-4">Cards Not Yet Studied</div>
<p class="text-danger fs-3 fw-bold">{{cardsNotYetStudied}}</p>
</div>
</div>

<!-- <button [routerLink]="'/leitner-set/study-session/' + setId" [disabled]="leitnerSetStartedToday && leitnerSetUnlearnedCount === 0" class="btn btn-primary w-25 mt-5 fs-5 w-auto text-center">-->
<!-- <fa-icon *ngIf="leitnerSetStartedToday && leitnerSetUnlearnedCount === 0" [icon]="faClock" class="ms-1"></fa-icon>-->
<!-- <fa-icon *ngIf="leitnerSetStartedToday && leitnerSetUnlearnedCount !== 0" [icon]="faForwardStep" class="ms-1"></fa-icon>-->
<!-- <fa-icon *ngIf="!leitnerSetStartedToday" [icon]="faPlay" class="ms-1"></fa-icon>-->
<!-- <span *ngIf="leitnerSetStartedToday && leitnerSetUnlearnedCount === 0"> Come back for tomorrow's study session</span>-->
<!-- <span *ngIf="leitnerSetStartedToday && leitnerSetUnlearnedCount !== 0"> Continue today's study session</span>-->
<!-- <span *ngIf="!leitnerSetStartedToday"> Start today's study session</span>-->
<!-- </button><br>-->
<!-- <div class="d-flex justify-content-end">-->
<!-- <button class="btn btn-secondary mt-3" (click)="this.leitnerModalRef = this.bsModalService.show(this.leitnerSettingsModal)">Settings</button>-->
<!-- </div>-->
</div>
</div>
<div *ngIf="!spacedRepetitionStarted">
<button class="btn btn-primary">Start</button>
</div>
</div>
</div>
<hr>
<div class="container-fluid">
<div class="d-flex justify-content-end align-items-center">
<div>
Expand Down
34 changes: 32 additions & 2 deletions apps/front/src/app/study-set/study-set.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ import { QuizletExportModalComponent } from "./quizlet-export-modal/quizlet-expo
import { faQuestionCircle } from "@fortawesome/free-regular-svg-icons";
import { faFileExport, faShareFromSquare, faPencil, faSave, faCancel, faTrashCan, faClipboard, faStar, faQ, faFileCsv, faImages } from "@fortawesome/free-solid-svg-icons";
import { ConvertingService } from "../shared/http/converting.service";
import { SpacedRepetitionService } from "../shared/http/spaced-repetition.service";
import { SharedService } from "../shared/shared.service";
import { SpacedRepetitionCard } from "@prisma/client";
import { DateTime } from "luxon";

@Component({
selector: "scholarsome-study-set",
Expand All @@ -30,7 +34,9 @@ export class StudySetComponent implements OnInit {
private readonly titleService: Title,
private readonly metaService: Meta,
private readonly setsService: SetsService,
private readonly convertingService: ConvertingService
private readonly convertingService: ConvertingService,
private readonly spacedRepetitionService: SpacedRepetitionService,
private readonly sharedService: SharedService
) {}

@ViewChild("spinner", { static: true }) spinner: ElementRef;
Expand All @@ -45,6 +51,7 @@ export class StudySetComponent implements OnInit {

protected userIsAuthor = false;
protected isEditing = false;
protected loggedIn = false;
protected setId: string | null;

protected author: string;
Expand All @@ -59,6 +66,11 @@ export class StudySetComponent implements OnInit {
protected uploadTooLarge = false;
protected deleteClicked = false;

protected spacedRepetitionStarted = false;
protected cardsNotYetStudied = 0;
protected cardsDueToday = 0;
protected cardsNewToday = 0;

// to disable clipboard button in share dropdown on non https
protected isHttps = true;

Expand Down Expand Up @@ -371,10 +383,28 @@ export class StudySetComponent implements OnInit {
this.metaService.addTag({ name: "description", content: description });

const user = await this.users.myUser();
if (user) this.loggedIn = true;

this.set = set;

if (user && user.id === set.authorId) this.userIsAuthor = true;
if (user) {
if (user.id === set.authorId) this.userIsAuthor = true;

const spacedRepetitionSet = await this.spacedRepetitionService.spacedRepetitionSet(this.setId);
if (spacedRepetitionSet) {
this.spacedRepetitionStarted = true;

const spacedRepetitionCards: (Omit<SpacedRepetitionCard, "due" | "lastStudiedAt"> & { due: DateTime, lastStudiedAt: DateTime })[] =
spacedRepetitionSet.spacedRepetitionCards.map((card): Omit<SpacedRepetitionCard, "due" | "lastStudiedAt"> & { due: DateTime, lastStudiedAt: DateTime } => ({
...card,
due: this.sharedService.convertUtcStringToTimeZone(card.due.toString(), user.timezone) ?? DateTime.now(),
lastStudiedAt: this.sharedService.convertUtcStringToTimeZone(card.lastStudiedAt.toString(), user.timezone) ?? DateTime.now()
}));

this.cardsNotYetStudied = spacedRepetitionCards.filter((c) => c.due.toMillis() === 1000).length;
this.cardsDueToday = spacedRepetitionCards.filter((c) => c.lastStudiedAt.hasSame(DateTime.now().setZone(user.timezone), "day")).length;
}
}

if (window.location.href.slice(0, 5) !== "https") {
this.isHttps = false;
Expand Down
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@
"joi": "^17.9.2",
"jwt-decode": "^3.1.2",
"katex": "^0.16.10",
"luxon": "^3.4.4",
"multer": "^1.4.5-lts.1",
"nest-winston": "^1.9.4",
"ng-recaptcha": "^10.0.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE `SpacedRepetitionCard` ADD COLUMN `lastStudiedAt` DATETIME(3) NOT NULL DEFAULT '1970-01-01T00:00:01+00:00';
1 change: 1 addition & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ model SpacedRepetitionCard {
easeFactor Float @default(2.5)
repetitions Int @default(0)
due DateTime @default("1970-01-01T00:00:01.000Z")
lastStudiedAt DateTime @default("1970-01-01T00:00:01.000Z")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
card Card @relation(fields: [cardId], references: [id], onDelete: Cascade, onUpdate: Cascade)
Expand Down

0 comments on commit 5d49e34

Please sign in to comment.