Added persistent prompt to student progress report

This commit is contained in:
ivan-pelly
2026-04-10 15:31:56 -07:00
parent d4a580ffae
commit b287276ec0
21 changed files with 606 additions and 11 deletions
@@ -45,6 +45,22 @@
</div>
}
<div class="field">
<div class="field-label-row">
<label class="field-label" for="prompt">Prompt</label>
@if (promptSaved()) {
<span class="save-indicator">&#10003; Saved</span>
}
</div>
<textarea
id="prompt"
class="field-input prompt-textarea"
rows="6"
[(ngModel)]="promptText"
(ngModelChange)="onPromptChange()">
</textarea>
</div>
<div class="actions">
<button
class="toolbar-btn run-btn"
@@ -55,6 +55,37 @@
}
}
.field-label-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
.field-label {
margin-bottom: 0;
}
}
.save-indicator {
font-size: 12px;
font-weight: 500;
color: var(--accent-green, #22c55e);
animation: fadeIn 0.2s ease-in;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.prompt-textarea {
width: 100%;
resize: vertical;
font-family: inherit;
font-size: 13px;
line-height: 1.5;
}
.run-btn {
background: var(--accent-indigo) !important;
color: #fff !important;
@@ -1,9 +1,9 @@
import { Component, inject, signal } from '@angular/core';
import { Component, inject, signal, OnDestroy } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { StudentService } from '../../../shared/services/student.service';
import { ReportPromptService } from '../../../shared/services/report-prompt.service';
import { StudentCardDto } from '../../../shared/classes/student-card.dto';
import { StudentGoalItem } from '../../../shared/classes/student-goal';
import { toIsoDateString } from '../../../shared/utils/format-date';
interface GoalCheckItem {
@@ -18,29 +18,41 @@ interface GoalCheckItem {
templateUrl: './student-progress-report.html',
styleUrl: './student-progress-report.scss',
})
export class StudentProgressReport {
export class StudentProgressReport implements OnDestroy {
// ************************** Constructor **************************
constructor() {
this.loadStudents();
this.loadPrompt();
}
// ************************** Declarations *************************
private readonly router = inject(Router);
private readonly studentService = inject(StudentService);
private readonly reportPromptService = inject(ReportPromptService);
protected readonly students = signal<StudentCardDto[]>([]);
protected readonly goalItems = signal<GoalCheckItem[]>([]);
protected readonly running = signal(false);
protected readonly promptSaved = signal(false);
protected selectedStudentId = '';
protected fromDate = '';
protected toDate = '';
protected promptText = '';
private promptId = '';
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
private savedTimer: ReturnType<typeof setTimeout> | null = null;
// ************************** Properties ***************************
// ************************ Public Methods *************************
ngOnDestroy() {
if (this.debounceTimer) clearTimeout(this.debounceTimer);
if (this.savedTimer) clearTimeout(this.savedTimer);
}
// ************************ Event Handlers *************************
onBack() {
@@ -88,6 +100,26 @@ export class StudentProgressReport {
);
}
// *****************************************************************
// Debounces prompt changes and auto-saves after 1 second of
// inactivity. Shows a brief "Saved" indicator on success.
// *****************************************************************
onPromptChange() {
this.promptSaved.set(false);
if (this.debounceTimer) clearTimeout(this.debounceTimer);
if (!this.promptId) return;
this.debounceTimer = setTimeout(async () => {
const result = await this.reportPromptService.updatePrompt(this.promptId, this.promptText);
if (result.success) {
this.promptSaved.set(true);
if (this.savedTimer) clearTimeout(this.savedTimer);
this.savedTimer = setTimeout(() => this.promptSaved.set(false), 3000);
}
}, 1000);
}
// *****************************************************************
// Calls the API to generate the markdown report, passing only
// the checked goal IDs, and triggers a browser download.
@@ -129,14 +161,34 @@ export class StudentProgressReport {
}
// *****************************************************************
// Triggers a browser download of the given markdown content.
// Loads the prompt for 'progressreport' from the API.
// *****************************************************************
private async loadPrompt() {
const result = await this.reportPromptService.getByReportname('progressreport');
if (result.success && result.payload) {
this.promptId = result.payload.reportPromptId;
this.promptText = result.payload.prompt;
} else {
console.error('[loadPrompt] Failed to load prompt:', result.message);
}
}
// *****************************************************************
// Triggers a browser download of the given markdown content,
// prepending the prompt text at the top of the file.
// *****************************************************************
private downloadMarkdown(content: string) {
const student = this.students().find(s => s.studentId === this.selectedStudentId);
const name = student ? student.identifier.replace(/\s+/g, '_') : 'report';
const filename = `${name}_progress_report_${this.fromDate}_to_${this.toDate}.md`;
const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' });
// Prepend the prompt if one exists.
let output = content;
if (this.promptText.trim()) {
output = this.promptText.trim() + '\n\n---\n\n' + content;
}
const blob = new Blob([output], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
@@ -0,0 +1,6 @@
export interface ReportPromptDto {
reportPromptId: string;
programId: string;
prompt: string;
reportname: string;
}
@@ -0,0 +1,59 @@
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { inject, Injectable } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { environment } from '../../../environments/environment';
import { ApiResult } from '../classes/api-result';
import { ResponseResult } from '../classes/auth.models';
import { ReportPromptDto } from '../classes/report-prompt.dto';
import { describeHttpError } from '../classes/http-errors';
@Injectable({
providedIn: 'root',
})
export class ReportPromptService {
// ************************** Declarations *************************
private readonly http = inject(HttpClient);
private readonly base = environment.apiBaseUrl;
// ************************ Public Methods *************************
// *****************************************************************
// Returns the report prompt for the given reportname, scoped to
// the authenticated user's program.
// *****************************************************************
async getByReportname(name: string): Promise<ApiResult<ReportPromptDto>> {
try {
const result = await firstValueFrom(
this.http.get<ResponseResult<ReportPromptDto>>(
`${this.base}/api/ReportPrompt/by-name/${encodeURIComponent(name)}`
)
);
return result.success && result.data
? ApiResult.ok(result.data)
: ApiResult.fail(result.message);
} catch (error) {
return ApiResult.fail(describeHttpError(error as HttpErrorResponse));
}
}
// *****************************************************************
// Updates the prompt text for an existing report prompt.
// *****************************************************************
async updatePrompt(id: string, prompt: string): Promise<ApiResult> {
try {
const result = await firstValueFrom(
this.http.put<ResponseResult<void>>(
`${this.base}/api/ReportPrompt/${id}`,
{ prompt }
)
);
return result.success
? ApiResult.empty()
: ApiResult.fail(result.message);
} catch (error) {
return ApiResult.fail(describeHttpError(error as HttpErrorResponse));
}
}
}