consolidated API endpoint for student data loading

This commit is contained in:
2026-04-08 17:10:34 -07:00
parent f798801212
commit e0a34f4c59
11 changed files with 348 additions and 101 deletions
@@ -3,7 +3,7 @@ import { FormsModule } from '@angular/forms';
import { ModalShell } from '../modal-shell/modal-shell';
import { StudentService } from '../../../shared/services/student.service';
import { BenchmarkDto } from '../../../shared/classes/benchmark.dto';
import { ProgressEventDto } from '../../../shared/classes/progress-event.dto';
import { ProgressEventWithGoalDto } from '../../../shared/classes/student-full-profile.dto';
import { GOAL_COLOR } from '../../../shared/classes/category-colors';
@Component({
@@ -19,8 +19,10 @@ export class EditEventModal {
readonly goalId = input.required<string>();
readonly benchmarks = input<BenchmarkDto[]>([]);
/** Benchmark IDs already associated with this event (from cached profile). */
readonly eventBenchmarkIds = input<string[]>([]);
/** null for new event, populated for edit */
readonly event = input<ProgressEventDto | null>(null);
readonly event = input<ProgressEventWithGoalDto | null>(null);
readonly saved = output<void>();
readonly closed = output<void>();
@@ -30,15 +32,11 @@ export class EditEventModal {
protected content = '';
async ngOnInit() {
ngOnInit() {
const ev = this.event();
if (ev) {
this.content = ev.content;
// Load existing benchmark associations
const result = await this.studentService.getProgressEventBenchmarks(ev.progressEventId);
if (result.success && result.payload) {
this.selectedBenchmarkIds.set(new Set(result.payload));
}
this.selectedBenchmarkIds.set(new Set(this.eventBenchmarkIds()));
}
}
@@ -19,6 +19,7 @@
@if (showEditEventModal()) {
<app-edit-event-modal [studentId]="studentId()!" [goalId]="selectedGoal()!.goalId"
[benchmarks]="goalBenchmarks()"
[eventBenchmarkIds]="showEditEventModal() !== 'new' && showEditEventModal() ? getBenchmarkIdsForEvent($any(showEditEventModal()).progressEventId) : []"
[event]="showEditEventModal() === 'new' ? null : $any(showEditEventModal())" (saved)="onEventSaved()"
(closed)="showEditEventModal.set(null)" />
}
@@ -68,7 +69,7 @@
</button>
<button class="sub-tab" [class.active]="activeTab() === 'progress'"
(click)="onTabChange('progress')">
Progress Events ({{ sortedProgressEvents().length }})
Progress Events ({{ goalProgressEvents().length }})
</button>
</div>
@@ -98,7 +99,7 @@
<div class="tab-content timeline">
<button class="add-btn" (click)="onNewEvent()">+ Log Progress Event</button>
<div class="timeline-line"></div>
@for (ev of sortedProgressEvents(); track ev.progressEventId) {
@for (ev of goalProgressEvents(); track ev.progressEventId) {
<div class="timeline-item">
<div class="timeline-dot"></div>
<div class="event-card">
@@ -4,7 +4,7 @@ import { StudentService } from '../../../shared/services/student.service';
import { StudentCardDto } from '../../../shared/classes/student-card.dto';
import { StudentGoalItem } from '../../../shared/classes/student-goal';
import { BenchmarkDto } from '../../../shared/classes/benchmark.dto';
import { ProgressEventDto } from '../../../shared/classes/progress-event.dto';
import { StudentFullProfileDto, ProgressEventWithGoalDto, ProgressEventBenchmarkLink } from '../../../shared/classes/student-full-profile.dto';
import { GoalModal } from '../goal-modal/goal-modal';
import { EditBenchmarkModal } from '../edit-benchmark-modal/edit-benchmark-modal';
import { EditEventModal } from '../edit-event-modal/edit-event-modal';
@@ -50,6 +50,7 @@ export class Workspace {
if (initialized) {
const id = untracked(() => this.studentId());
if (id) {
this.studentService.invalidateProfile(id);
this.loadStudentData(id);
}
}
@@ -67,14 +68,15 @@ export class Workspace {
protected readonly student = signal<StudentCardDto | null>(null);
protected readonly goals = signal<StudentGoalItem[]>([]);
protected readonly benchmarks = signal<BenchmarkDto[]>([]);
protected readonly progressEvents = signal<ProgressEventDto[]>([]);
protected readonly progressEvents = signal<ProgressEventWithGoalDto[]>([]);
protected readonly progressEventBenchmarks = signal<ProgressEventBenchmarkLink[]>([]);
protected readonly selectedGoalId = signal<string | null>(null);
protected readonly activeTab = signal<TabView>('benchmarks');
// Modal states
protected readonly showGoalModal = signal<StudentGoalItem | 'add' | null>(null);
protected readonly showEditBenchmarkModal = signal<BenchmarkDto | 'new' | null>(null);
protected readonly showEditEventModal = signal<ProgressEventDto | null | 'new'>(null);
protected readonly showEditEventModal = signal<ProgressEventWithGoalDto | null | 'new'>(null);
// ************************** Properties ***************************
@@ -91,8 +93,11 @@ export class Workspace {
return this.benchmarks().filter(b => b.goalId === goalId);
});
protected readonly sortedProgressEvents = computed<ProgressEventDto[]>(() => {
return [...this.progressEvents()]
protected readonly goalProgressEvents = computed<ProgressEventWithGoalDto[]>(() => {
const goalId = this.selectedGoal()?.goalId;
if (!goalId) return [];
return this.progressEvents()
.filter(e => e.goalId === goalId)
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
});
@@ -107,7 +112,6 @@ export class Workspace {
onSelectGoal(goalId: string) {
this.selectedGoalId.set(goalId);
this.activeTab.set('benchmarks');
this.loadGoalDetails(goalId);
this.router.navigate(['/students', this.studentId(), 'goals', goalId]);
}
@@ -122,7 +126,7 @@ export class Workspace {
onGoalSaved() {
this.showGoalModal.set(null);
this.loadStudentData(this.studentId()!);
this.refetchProfile();
}
onAddGoal() {
@@ -132,9 +136,8 @@ export class Workspace {
onGoalCreated(goal: StudentGoalItem) {
this.showGoalModal.set(null);
this.studentService.notifyDataChanged();
this.loadStudentData(this.studentId()!).then(() => {
this.refetchProfile().then(() => {
this.selectedGoalId.set(goal.goalId);
this.loadGoalDetails(goal.goalId);
});
}
@@ -144,7 +147,7 @@ export class Workspace {
onEditBenchmarkSaved() {
this.showEditBenchmarkModal.set(null);
this.loadStudentData(this.studentId()!);
this.refetchProfile();
}
onAddBenchmark() {
@@ -155,15 +158,23 @@ export class Workspace {
this.showEditEventModal.set('new');
}
onEditEvent(ev: ProgressEventDto) {
onEditEvent(ev: ProgressEventWithGoalDto) {
this.showEditEventModal.set(ev);
}
onEventSaved() {
this.showEditEventModal.set(null);
if (this.selectedGoal()) {
this.loadGoalDetails(this.selectedGoal()!.goalId);
}
this.refetchProfile();
}
// *****************************************************************
// Returns the benchmark IDs associated with a given progress event,
// read from the cached profile data.
// *****************************************************************
getBenchmarkIdsForEvent(progressEventId: string): string[] {
return this.progressEventBenchmarks()
.filter(link => link.progressEventId === progressEventId)
.map(link => link.benchmarkId);
}
// ************************ Formatting Helpers **********************
@@ -177,39 +188,27 @@ export class Workspace {
// ********************** Support Procedures ***********************
private async loadStudentData(studentId: string) {
const [studentResult, goalsResult, bmResult] = await Promise.all([
this.studentService.getStudentById(studentId),
this.studentService.getGoalsForStudent(studentId),
this.studentService.getBenchmarksForStudent(studentId),
]);
const result = await this.studentService.getFullProfile(studentId);
if (studentResult.success && studentResult.payload) {
this.student.set(studentResult.payload);
}
if (!result.success || !result.payload) return;
if (goalsResult.success && goalsResult.payload) {
this.goals.set(goalsResult.payload.goals);
// Auto-select first goal if none selected
if (!this.selectedGoalId() && goalsResult.payload.goals.length > 0) {
this.selectedGoalId.set(goalsResult.payload.goals[0].goalId);
}
}
const profile = result.payload;
this.student.set(profile.student);
this.goals.set(profile.goals);
this.benchmarks.set(profile.benchmarks);
this.progressEvents.set(profile.progressEvents);
this.progressEventBenchmarks.set(profile.progressEventBenchmarks);
if (bmResult.success && bmResult.payload) {
this.benchmarks.set(bmResult.payload.benchmarks);
}
// Load progress events for selected goal
const goalId = this.selectedGoalId();
if (goalId) {
this.loadGoalDetails(goalId);
// Auto-select first goal if none selected
if (!this.selectedGoalId() && profile.goals.length > 0) {
this.selectedGoalId.set(profile.goals[0].goalId);
}
}
private async loadGoalDetails(goalId: string) {
const result = await this.studentService.getProgressEventsForGoal(goalId);
if (result.success) {
this.progressEvents.set(result.payload ?? []);
}
private async refetchProfile(): Promise<void> {
const id = this.studentId();
if (!id) return;
this.studentService.invalidateProfile(id);
await this.loadStudentData(id);
}
}
@@ -0,0 +1,24 @@
import { StudentCardDto } from './student-card.dto';
import { StudentGoalItem } from './student-goal';
import { BenchmarkDto } from './benchmark.dto';
export interface StudentFullProfileDto {
student: StudentCardDto;
goals: StudentGoalItem[];
benchmarks: BenchmarkDto[];
progressEvents: ProgressEventWithGoalDto[];
progressEventBenchmarks: ProgressEventBenchmarkLink[];
}
export interface ProgressEventWithGoalDto {
progressEventId: string;
goalId: string;
content: string;
createdAt: Date;
createdByName: string;
}
export interface ProgressEventBenchmarkLink {
progressEventId: string;
benchmarkId: string;
}
@@ -9,9 +9,9 @@ import { CreateStudentDto } from '../classes/create-student.dto';
import { CreateGoalDto } from '../classes/create-goal.dto';
import { StudentCardDto } from '../classes/student-card.dto';
import { StudentGoalSummary, StudentGoalItem } from '../classes/student-goal';
import { ProgressEventDto } from '../classes/progress-event.dto';
import { StudentBenchmarkSummary } from '../classes/benchmark.dto';
import { StudentProgressReportDto } from '../classes/student-progress-report.dto';
import { StudentFullProfileDto } from '../classes/student-full-profile.dto';
@Injectable({
providedIn: 'root',
@@ -28,6 +28,9 @@ export class StudentService {
// Incremented after any data mutation so subscribers can refresh.
readonly dataVersion = signal(0);
// Per-student full profile cache.
private readonly profileCache = new Map<string, StudentFullProfileDto>();
// Emits targeted label updates for sidebar nodes without a full rebuild.
private readonly _sidebarLabelUpdate = new Subject<{ routerLink: string[]; label: string }>();
readonly sidebarLabelUpdate$ = this._sidebarLabelUpdate.asObservable();
@@ -43,6 +46,41 @@ export class StudentService {
this.dataVersion.update(v => v + 1);
}
// *****************************************************************
// Returns the full profile for a student. Uses a per-student cache
// so subsequent loads are instant. Call invalidateProfile() after
// mutations to force a fresh fetch.
// *****************************************************************
async getFullProfile(studentId: string): Promise<ApiResult<StudentFullProfileDto>> {
const cached = this.profileCache.get(studentId);
if (cached) return ApiResult.ok(cached);
try {
const result = await firstValueFrom(
this.http.get<ResponseResult<StudentFullProfileDto>>(`${this.base}/api/Student/${studentId}/full`)
);
if (result.success && result.data) {
this.profileCache.set(studentId, result.data);
return ApiResult.ok(result.data);
}
return ApiResult.fail(result.message);
} catch (error) {
return ApiResult.fail(describeHttpError(error as HttpErrorResponse));
}
}
// *****************************************************************
// Removes a student's cached profile so the next getFullProfile
// call fetches fresh data. Pass no argument to clear all.
// *****************************************************************
invalidateProfile(studentId?: string) {
if (studentId) {
this.profileCache.delete(studentId);
} else {
this.profileCache.clear();
}
}
// *****************************************************************
// Emits a targeted sidebar label update for a specific node,
// avoiding the full tree rebuild that notifyDataChanged triggers.
@@ -152,38 +190,6 @@ export class StudentService {
}
}
// *****************************************************************
// Returns benchmark IDs associated with a progress event.
// *****************************************************************
async getProgressEventBenchmarks(progressEventId: string): Promise<ApiResult<string[]>> {
try {
const result = await firstValueFrom(
this.http.get<ResponseResult<string[]>>(`${this.base}/api/Student/progress-events/${progressEventId}/benchmarks`)
);
return result.success
? ApiResult.ok(result.data ?? [])
: ApiResult.fail(result.message);
} catch (error) {
return ApiResult.fail(describeHttpError(error as HttpErrorResponse));
}
}
// *****************************************************************
// Returns progress events for a given student goal.
// *****************************************************************
async getProgressEventsForGoal(goalId: string): Promise<ApiResult<ProgressEventDto[]>> {
try {
const result = await firstValueFrom(
this.http.get<ResponseResult<ProgressEventDto[]>>(`${this.base}/api/Student/goals/${goalId}/progress-events`)
);
return result.success
? ApiResult.ok(result.data ?? [])
: ApiResult.fail(result.message);
} catch (error) {
return ApiResult.fail(describeHttpError(error as HttpErrorResponse));
}
}
// *****************************************************************
// Returns a full progress report for a student within a date
// range, including goals, events, and benchmark associations.
@@ -211,22 +217,6 @@ export class StudentService {
// ********************** Support Procedures ***********************
// *****************************************************************
// Returns a single student by ID.
// *****************************************************************
async getStudentById(studentId: string): Promise<ApiResult<StudentCardDto>> {
try {
const result = await firstValueFrom(
this.http.get<ResponseResult<StudentCardDto>>(`${this.base}/api/Student/${studentId}`)
);
return result.success && result.data
? ApiResult.ok(result.data)
: ApiResult.fail(result.message);
} catch (error) {
return ApiResult.fail(describeHttpError(error as HttpErrorResponse));
}
}
// *****************************************************************
// Updates a student and returns the refreshed student data.
// *****************************************************************