Updates to encompass benchmarks

This commit is contained in:
ivan-pelly
2026-03-07 16:10:55 -08:00
parent 69e96403f4
commit 3d531298e2
65 changed files with 2505 additions and 86 deletions
@@ -0,0 +1,44 @@
<div class="toolbar">
<button class="toolbar-btn back-btn" (click)="onBack()">&#8593; Benchmarks</button>
<span class="toolbar-title">{{ isNew() ? 'New Benchmark' : 'Benchmark Detail' }}</span>
<span class="spacer"></span>
</div>
@if (errorMessage()) {
<p class="error">{{ errorMessage() }}</p>
}
@if (successMessage()) {
<p class="success">{{ successMessage() }}</p>
}
@if (loaded()) {
<div class="detail-card">
<div class="field">
<span class="field-label">Goal</span>
<span class="field-value">{{ goalTitle }}</span>
</div>
<div class="field">
<label class="field-label" for="benchmarkText">Benchmark</label>
<textarea id="benchmarkText" class="field-input field-textarea" [(ngModel)]="benchmarkText" rows="4"
placeholder="Enter benchmark text..."></textarea>
</div>
@if (!isNew()) {
<div class="metadata">
@if (createdByName) {
<span class="meta-item">Created by: {{ createdByName }}</span>
}
<span class="meta-item">Created: {{ createdAt | date:'medium' }}</span>
@if (updatedAt) {
<span class="meta-item">Updated: {{ updatedAt | date:'medium' }}</span>
}
</div>
}
<div class="actions">
<button class="toolbar-btn" (click)="onCancel()" [disabled]="!hasChanges()">Cancel</button>
<button class="toolbar-btn save-btn" (click)="onSave()" [disabled]="!hasChanges() || saving()">
{{ saving() ? 'Saving...' : 'Save' }}
</button>
</div>
</div>
}
@@ -0,0 +1,146 @@
:host {
display: flex;
flex-direction: column;
height: 100%;
}
.toolbar {
display: flex;
align-items: center;
position: relative;
gap: 0.75rem;
height: 40px;
padding-right: 0.5rem;
border-radius: 8px;
background: #fff;
border-bottom: 1px solid #ddd;
margin-bottom: 1rem;
flex-shrink: 0;
}
.toolbar-btn {
padding: 0.375rem 0.75rem;
background: transparent;
color: #4f46e5;
border: 1px solid #4f46e5;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
}
.toolbar-btn:hover {
background: #eef2ff;
}
.toolbar-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.back-btn {
margin-left: 0.5rem;
}
.toolbar-title {
position: absolute;
left: 50%;
transform: translateX(-50%);
font-weight: 600;
font-size: 1.25rem;
color: #333;
}
.spacer {
flex: 1;
}
.error {
font-size: 0.875rem;
color: #dc2626;
margin: 0 0 1rem;
}
.success {
font-size: 0.875rem;
color: #16a34a;
margin: 0 0 1rem;
}
.detail-card {
background: #fff;
border: 1px solid #ddd;
border-radius: 8px;
padding: 1.5rem;
max-width: 600px;
}
.field {
display: flex;
flex-direction: column;
margin-bottom: 1rem;
}
.field-label {
font-size: 0.75rem;
font-weight: 600;
color: #666;
text-transform: uppercase;
letter-spacing: 0.025em;
margin-bottom: 0.25rem;
}
.field-value {
font-size: 0.9375rem;
color: #333;
}
.field-input {
padding: 0.375rem 0.5rem;
border: 1px solid #ccc;
border-radius: 6px;
font-size: 0.9375rem;
outline: none;
}
.field-input:focus {
border-color: #4f46e5;
}
.field-textarea {
font-family: inherit;
resize: vertical;
min-height: 5rem;
}
.metadata {
display: flex;
gap: 1.5rem;
margin-bottom: 1rem;
}
.meta-item {
font-size: 0.8125rem;
color: #888;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.5rem;
}
.actions .toolbar-btn {
min-width: 6rem;
}
.save-btn {
background: #4f46e5;
color: #fff;
border-color: #4f46e5;
}
.save-btn:hover {
background: #4338ca;
}
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { BenchmarkCardFull } from './benchmark-card-full';
describe('BenchmarkCardFull', () => {
let component: BenchmarkCardFull;
let fixture: ComponentFixture<BenchmarkCardFull>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [BenchmarkCardFull]
})
.compileComponents();
fixture = TestBed.createComponent(BenchmarkCardFull);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,169 @@
import { Component, inject, signal, OnDestroy } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { DatePipe } from '@angular/common';
import { Subscription } from 'rxjs';
import { StudentService } from '../../../shared/services/student.service';
import { BenchmarkDto } from '../../../shared/classes/benchmark.dto';
@Component({
selector: 'app-benchmark-card-full',
imports: [FormsModule, DatePipe],
templateUrl: './benchmark-card-full.html',
styleUrl: './benchmark-card-full.scss',
})
export class BenchmarkCardFull implements OnDestroy {
// ************************** Constructor **************************
constructor() {
this.paramSub = this.route.paramMap.subscribe(params => {
this.studentId = params.get('studentId')!;
this.goalId = params.get('goalId')!;
this.benchmarkId = params.get('benchmarkId') ?? null;
this.loadBenchmark();
});
}
// ************************** Declarations *************************
private readonly studentService = inject(StudentService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly paramSub: Subscription;
private studentId!: string;
private goalId!: string;
private benchmarkId: string | null = null;
protected readonly loaded = signal(false);
protected readonly isNew = signal(false);
protected readonly errorMessage = signal<string | null>(null);
protected readonly successMessage = signal<string | null>(null);
protected readonly saving = signal(false);
// Form field
protected benchmarkText = '';
private savedBenchmarkText = '';
// Read-only metadata
protected goalTitle = '';
protected createdByName = '';
protected createdAt: Date | null = null;
protected updatedAt: Date | null = null;
// ************************** Properties ***************************
// *****************************************************************
// Returns true if the benchmark text has unsaved changes.
// *****************************************************************
hasChanges(): boolean {
return this.benchmarkText !== this.savedBenchmarkText;
}
// ************************ Public Methods *************************
// ************************ Event Handlers *************************
// *****************************************************************
// Saves changes or creates a new benchmark.
// *****************************************************************
async onSave() {
this.saving.set(true);
this.errorMessage.set(null);
this.successMessage.set(null);
if (this.isNew()) {
const result = await this.studentService.createBenchmark(this.studentId, {
goalId: this.goalId,
benchmark: this.benchmarkText,
});
this.saving.set(false);
if (result.success) {
this.successMessage.set('Benchmark created.');
this.savedBenchmarkText = this.benchmarkText;
if (result.payload?.benchmarkId) {
this.router.navigate(['/students', this.studentId, 'goals', this.goalId, 'benchmarks', result.payload.benchmarkId]);
}
} else {
this.errorMessage.set(result.message);
}
} else {
const result = await this.studentService.updateBenchmark(this.studentId, this.benchmarkId!, this.benchmarkText);
this.saving.set(false);
if (result.success) {
this.savedBenchmarkText = this.benchmarkText;
this.successMessage.set('Changes saved.');
} else {
this.errorMessage.set(result.message);
}
}
}
// *****************************************************************
// Reverts the benchmark text to the last-saved value.
// *****************************************************************
onCancel() {
this.benchmarkText = this.savedBenchmarkText;
this.errorMessage.set(null);
this.successMessage.set(null);
}
onBack() {
this.router.navigate(['/students', this.studentId, 'benchmarks']);
}
ngOnDestroy() {
this.paramSub.unsubscribe();
}
// ********************** Support Procedures ***********************
// *****************************************************************
// Loads existing benchmark data or sets up new-benchmark state.
// *****************************************************************
private loadBenchmark() {
if (!this.benchmarkId) {
this.isNew.set(true);
this.benchmarkText = '';
this.savedBenchmarkText = '';
this.loadGoalTitle();
this.loaded.set(true);
return;
}
this.isNew.set(false);
this.studentService.getBenchmarksForStudent(this.studentId).then(result => {
if (!result.success || !result.payload) {
this.errorMessage.set(result.message);
return;
}
const bm = result.payload.benchmarks.find(b => b.benchmarkId === this.benchmarkId);
if (!bm) {
this.errorMessage.set('Benchmark not found.');
return;
}
this.benchmarkText = bm.benchmark;
this.savedBenchmarkText = bm.benchmark;
this.goalTitle = bm.goalTitle;
this.createdByName = bm.createdByName;
this.createdAt = bm.createdAt;
this.updatedAt = bm.updatedAt;
this.loaded.set(true);
});
}
// *****************************************************************
// Loads the goal title for a new benchmark.
// *****************************************************************
private loadGoalTitle() {
this.studentService.getGoalsForStudent(this.studentId).then(result => {
if (result.success && result.payload) {
const goal = result.payload.goals.find(g => g.goalId === this.goalId);
this.goalTitle = goal?.title ?? '';
}
});
}
}
@@ -0,0 +1,12 @@
<div class="card">
<div class="card-header">
<span class="goal-badge">{{ benchmark().goalTitle }}</span>
@if (benchmark().updatedAt) {
<span class="date">Updated: {{ benchmark().updatedAt | date:'M/d/yy' }}</span>
} @else {
<span class="date">{{ benchmark().createdAt | date:'M/d/yy' }}</span>
}
</div>
<p class="benchmark-text">{{ benchmark().benchmark }}</p>
</div>
@@ -0,0 +1,51 @@
:host {
display: block;
width: 300px;
}
.card {
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
padding: 1.25rem 1.5rem;
display: flex;
flex-direction: column;
gap: 0.625rem;
min-width: 0;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.goal-badge {
padding: 0.2rem 0.6rem;
background: #f0fdf4;
color: #16a34a;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 60%;
}
.date {
font-size: 0.8125rem;
color: #888;
}
.benchmark-text {
margin: 0;
font-size: 0.875rem;
color: #333;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { BenchmarkCard } from './benchmark-card';
describe('BenchmarkCard', () => {
let component: BenchmarkCard;
let fixture: ComponentFixture<BenchmarkCard>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [BenchmarkCard]
})
.compileComponents();
fixture = TestBed.createComponent(BenchmarkCard);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,26 @@
import { Component, input } from '@angular/core';
import { DatePipe } from '@angular/common';
import { BenchmarkDto } from '../../../shared/classes/benchmark.dto';
@Component({
selector: 'app-benchmark-card',
imports: [DatePipe],
templateUrl: './benchmark-card.html',
styleUrl: './benchmark-card.scss',
})
export class BenchmarkCard {
// ************************** Constructor **************************
// ************************** Declarations *************************
readonly benchmark = input.required<BenchmarkDto>();
// ************************** Properties ***************************
// ************************ Public Methods *************************
// ************************ Event Handlers *************************
// ********************** Support Procedures ***********************
}
@@ -0,0 +1,24 @@
<div class="toolbar">
<button class="toolbar-btn back-btn" (click)="onBack()">&#8593; Goal</button>
<span class="spacer"></span>
<button class="toolbar-btn" (click)="onAddBenchmark()">+ Add a Benchmark</button>
</div>
@if (studentIdentifier()) {
<h2 class="section-header">Benchmarks for {{ studentIdentifier() }}</h2>
}
@if (errorMessage()) {
<p class="error">{{ errorMessage() }}</p>
}
@if (benchmarks().length === 0 && !errorMessage()) {
<p class="empty-state">No benchmarks yet. Click <a class="empty-link" (click)="onAddBenchmark()">Add a Benchmark</a> to
get started.</p>
} @else {
<div class="card-grid">
@for (bm of benchmarks(); track bm.benchmarkId) {
<app-benchmark-card [benchmark]="bm" />
}
</div>
}
@@ -0,0 +1,75 @@
:host {
display: flex;
flex-direction: column;
height: 100%;
}
.toolbar {
display: flex;
align-items: center;
gap: 0.75rem;
height: 40px;
padding-right: 0.5rem;
border-radius: 8px;
background: #fff;
border-bottom: 1px solid #ddd;
margin-bottom: 1rem;
flex-shrink: 0;
}
.toolbar-btn {
padding: 0.375rem 0.75rem;
background: transparent;
color: #4f46e5;
border: 1px solid #4f46e5;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
}
.toolbar-btn:hover {
background: #eef2ff;
}
.back-btn {
margin-left: 0.5rem;
}
.section-header {
font-size: 1.125rem;
font-weight: 600;
color: #333;
margin: 0 0 0.5rem;
}
.spacer {
flex: 1;
}
.error {
font-size: 0.875rem;
color: #dc2626;
margin: 0 0 1rem;
}
.empty-state {
color: #888;
font-size: 0.9375rem;
margin: 2rem auto;
text-align: center;
}
.empty-link {
color: #4f46e5;
text-decoration: underline;
cursor: pointer;
}
.card-grid {
display: flex;
flex-wrap: wrap;
gap: 1rem;
overflow-y: auto;
flex: 1;
}
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { BenchmarkList } from './benchmark-list';
describe('BenchmarkList', () => {
let component: BenchmarkList;
let fixture: ComponentFixture<BenchmarkList>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [BenchmarkList]
})
.compileComponents();
fixture = TestBed.createComponent(BenchmarkList);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,64 @@
import { Component, inject, signal } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { BenchmarkDto } from '../../../shared/classes/benchmark.dto';
import { StudentService } from '../../../shared/services/student.service';
import { BenchmarkCard } from '../benchmark-card/benchmark-card';
@Component({
selector: 'app-benchmark-list',
imports: [BenchmarkCard],
templateUrl: './benchmark-list.html',
styleUrl: './benchmark-list.scss',
})
export class BenchmarkList {
// ************************** Constructor **************************
constructor() {
this.studentId = this.route.snapshot.paramMap.get('studentId')!;
this.goalId = this.route.snapshot.paramMap.get('goalId') || '';
this.loadBenchmarks();
}
// ************************** Declarations *************************
private readonly studentService = inject(StudentService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
protected readonly studentId: string;
protected readonly goalId: string;
protected readonly studentIdentifier = signal<string | null>(null);
protected readonly benchmarks = signal<BenchmarkDto[]>([]);
protected readonly errorMessage = signal<string | null>(null);
// ************************** Properties ***************************
// ************************ Public Methods *************************
// ************************ Event Handlers *************************
onAddBenchmark() {
this.router.navigate(['/students', this.studentId, 'goals', this.goalId, 'benchmarks', 'new']);
}
onBack() {
this.router.navigate(['/students', this.studentId, 'goals', this.goalId]);
}
// ********************** Support Procedures ***********************
// *****************************************************************
// Loads benchmarks for the student from the service.
// *****************************************************************
private loadBenchmarks() {
this.studentService.getBenchmarksForStudent(this.studentId).then(data => {
if (!data.success) {
this.errorMessage.set(data.message);
} else {
this.studentIdentifier.set(data.payload?.studentIdentifier ?? null);
this.benchmarks.set(data.payload?.benchmarks ?? []);
}
});
}
}
@@ -0,0 +1,42 @@
<div class="toolbar">
<button class="toolbar-btn back-btn" (click)="onBack()">&#8593; Student</button>
<span class="toolbar-title">Goal Detail</span>
<span class="spacer"></span>
</div>
@if (errorMessage()) {
<p class="error">{{ errorMessage() }}</p>
}
@if (successMessage()) {
<p class="success">{{ successMessage() }}</p>
}
@if (loaded()) {
<div class="detail-card">
<div class="field">
<label class="field-label" for="title">Title</label>
<input id="title" class="field-input" type="text" [(ngModel)]="title" />
</div>
<div class="field">
<label class="field-label" for="description">Description</label>
<textarea id="description" class="field-input field-textarea" [(ngModel)]="description" rows="4"
placeholder="Enter description..."></textarea>
</div>
<div class="field">
<label class="field-label" for="category">Category</label>
<input id="category" class="field-input" type="text" [(ngModel)]="category" />
</div>
<div class="actions">
<button class="toolbar-btn" (click)="onCancel()" [disabled]="!hasChanges()">Cancel</button>
<button class="toolbar-btn save-btn" (click)="onSave()" [disabled]="!hasChanges() || saving()">
{{ saving() ? 'Saving...' : 'Save' }}
</button>
</div>
<div class="card-footer">
<a class="detail-link" (click)="onProgressEvents()">Progress Events</a>
<a class="detail-link" (click)="onBenchmarks()">Benchmarks</a>
</div>
</div>
}
@@ -0,0 +1,149 @@
:host {
display: flex;
flex-direction: column;
height: 100%;
}
.toolbar {
display: flex;
align-items: center;
position: relative;
gap: 0.75rem;
height: 40px;
padding-right: 0.5rem;
border-radius: 8px;
background: #fff;
border-bottom: 1px solid #ddd;
margin-bottom: 1rem;
flex-shrink: 0;
}
.toolbar-btn {
padding: 0.375rem 0.75rem;
background: transparent;
color: #4f46e5;
border: 1px solid #4f46e5;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
}
.toolbar-btn:hover {
background: #eef2ff;
}
.toolbar-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.back-btn {
margin-left: 0.5rem;
}
.toolbar-title {
position: absolute;
left: 50%;
transform: translateX(-50%);
font-weight: 600;
font-size: 1.25rem;
color: #333;
}
.spacer {
flex: 1;
}
.error {
font-size: 0.875rem;
color: #dc2626;
margin: 0 0 1rem;
}
.success {
font-size: 0.875rem;
color: #16a34a;
margin: 0 0 1rem;
}
.detail-card {
background: #fff;
border: 1px solid #ddd;
border-radius: 8px;
padding: 1.5rem;
max-width: 600px;
}
.card-footer {
display: flex;
gap: 1.5rem;
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #eee;
}
.detail-link {
font-size: 0.875rem;
color: #4f46e5;
cursor: pointer;
text-decoration: underline;
}
.detail-link:hover {
color: #4338ca;
}
.field {
display: flex;
flex-direction: column;
margin-bottom: 1rem;
}
.field-label {
font-size: 0.75rem;
font-weight: 600;
color: #666;
text-transform: uppercase;
letter-spacing: 0.025em;
margin-bottom: 0.25rem;
}
.field-input {
padding: 0.375rem 0.5rem;
border: 1px solid #ccc;
border-radius: 6px;
font-size: 0.9375rem;
outline: none;
}
.field-input:focus {
border-color: #4f46e5;
}
.field-textarea {
font-family: inherit;
resize: vertical;
min-height: 5rem;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.5rem;
}
.actions .toolbar-btn {
min-width: 6rem;
}
.save-btn {
background: #4f46e5;
color: #fff;
border-color: #4f46e5;
}
.save-btn:hover {
background: #4338ca;
}
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { GoalCardFull } from './goal-card-full';
describe('GoalCardFull', () => {
let component: GoalCardFull;
let fixture: ComponentFixture<GoalCardFull>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [GoalCardFull]
})
.compileComponents();
fixture = TestBed.createComponent(GoalCardFull);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,154 @@
import { Component, inject, signal, OnDestroy } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { Subscription } from 'rxjs';
import { StudentService } from '../../../shared/services/student.service';
import { StudentGoalItem } from '../../../shared/classes/student-goal';
@Component({
selector: 'app-goal-card-full',
imports: [FormsModule],
templateUrl: './goal-card-full.html',
styleUrl: './goal-card-full.scss',
})
export class GoalCardFull implements OnDestroy {
// ************************** Constructor **************************
constructor() {
this.paramSub = this.route.paramMap.subscribe(params => {
this.studentId = params.get('studentId')!;
this.goalId = params.get('goalId')!;
this.loadGoal();
});
}
// ************************** Declarations *************************
private readonly studentService = inject(StudentService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly paramSub: Subscription;
private studentId!: string;
private goalId!: string;
protected readonly loaded = signal(false);
protected readonly errorMessage = signal<string | null>(null);
protected readonly successMessage = signal<string | null>(null);
protected readonly saving = signal(false);
// Form fields
protected title = '';
protected description = '';
protected category = '';
// Read-only metadata
protected progressEventCount = 0;
protected benchmarkCount = 0;
// Snapshot
private savedTitle = '';
private savedDescription = '';
private savedCategory = '';
// ************************** Properties ***************************
// *****************************************************************
// Returns true if form values differ from the saved snapshot.
// *****************************************************************
hasChanges(): boolean {
return this.title !== this.savedTitle
|| this.description !== this.savedDescription
|| this.category !== this.savedCategory;
}
// ************************ Public Methods *************************
// ************************ Event Handlers *************************
// *****************************************************************
// Saves changes to the goal via the API.
// *****************************************************************
async onSave() {
this.saving.set(true);
this.errorMessage.set(null);
this.successMessage.set(null);
const result = await this.studentService.updateGoal(this.studentId, this.goalId, {
title: this.title,
description: this.description,
category: this.category,
});
this.saving.set(false);
if (result.success) {
this.savedTitle = this.title;
this.savedDescription = this.description;
this.savedCategory = this.category;
this.successMessage.set('Changes saved.');
} else {
this.errorMessage.set(result.message);
}
}
// *****************************************************************
// Reverts form fields to the last-saved snapshot.
// *****************************************************************
onCancel() {
this.title = this.savedTitle;
this.description = this.savedDescription;
this.category = this.savedCategory;
this.errorMessage.set(null);
this.successMessage.set(null);
}
onBack() {
this.router.navigate(['/students', this.studentId]);
}
onProgressEvents() {
this.router.navigate(['/students', this.studentId, 'goals', this.goalId, 'progress']);
}
onBenchmarks() {
this.router.navigate(['/students', this.studentId, 'benchmarks']);
}
ngOnDestroy() {
this.paramSub.unsubscribe();
}
// ********************** Support Procedures ***********************
// *****************************************************************
// Loads the goal by finding it in the student's goal list.
// *****************************************************************
private loadGoal() {
this.loaded.set(false);
this.studentService.getGoalsForStudent(this.studentId).then(result => {
if (!result.success || !result.payload) {
this.errorMessage.set(result.message);
return;
}
const goal = result.payload.goals.find(g => g.goalId === this.goalId);
if (!goal) {
this.errorMessage.set('Goal not found.');
return;
}
this.title = goal.title;
this.description = goal.description;
this.category = goal.category;
this.progressEventCount = goal.progressEventCount;
this.benchmarkCount = goal.benchmarkCount;
this.savedTitle = goal.title;
this.savedDescription = goal.description;
this.savedCategory = goal.category;
this.loaded.set(true);
});
}
}
@@ -1,4 +1,4 @@
<div class="card clickable" (click)="onCardClick()">
<div class="card" (click)="onCardClick()">
<div class="card-header">
<span class="category-badge">{{ goal().category }}</span>
<span class="event-count">{{ goal().progressEventCount }} events</span>
@@ -6,4 +6,9 @@
<h3 class="title">{{ goal().title }}</h3>
<p class="description">{{ goal().description }}</p>
<div class="card-footer">
<a class="footer-link" (click)="$event.stopPropagation(); onBenchmarksClick()">Benchmarks</a>
<a class="footer-link" (click)="$event.stopPropagation(); onProgressEventsClick()">Progress Events</a>
</div>
</div>
@@ -11,16 +11,29 @@
display: flex;
flex-direction: column;
gap: 0.625rem;
height: 130px;
cursor: pointer;
min-width: 0;
}
.card.clickable {
cursor: pointer;
.card-footer {
display: flex;
align-items: center;
gap: 1.5rem;
margin: 0 -1.5rem -1rem;
padding: 0.5rem 1.5rem 0.5rem;
border-top: 1px solid #eee;
}
.card.clickable:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
.footer-link {
font-size: 0.875rem;
color: #4f46e5;
cursor: pointer;
text-decoration: underline;
}
.footer-link:hover {
color: #4338ca;
}
.card-header {
@@ -26,9 +26,25 @@ export class GoalCard {
// ************************ Event Handlers *************************
// *****************************************************************
// Navigates to the progress events page for this goal.
// Navigates to the goal detail page.
// *****************************************************************
onCardClick() {
const studentId = this.route.snapshot.paramMap.get('studentId')!;
this.router.navigate(['/students', studentId, 'goals', this.goal().goalId]);
}
// *****************************************************************
// Navigates to the benchmarks page for this goal.
// *****************************************************************
onBenchmarksClick() {
const studentId = this.route.snapshot.paramMap.get('studentId')!;
this.router.navigate(['/students', studentId, 'goals', this.goal().goalId, 'benchmarks']);
}
// *****************************************************************
// Navigates to the progress events page for this goal.
// *****************************************************************
onProgressEventsClick() {
const studentId = this.route.snapshot.paramMap.get('studentId')!;
this.router.navigate(['/students', studentId, 'goals', this.goal().goalId, 'progress']);
}
@@ -1,12 +1,15 @@
<div class="toolbar">
<button class="toolbar-btn back-btn" (click)="onBack()">&#8592; Students</button>
<button class="toolbar-btn back-btn" (click)="onBack()">&#8593; Student</button>
<span class="toolbar-title">Goals</span>
<span class="spacer"></span>
<button class="generate-report"> ⭐ Generate progress report</button>
<button class="toolbar-btn" (click)="onAddGoal()">+ Add a Goal</button>
</div>
<!-- <img class="hero-image" src="/hurdlescropped.png" alt="Hurdles" /> -->
@if (studentIdentifier()) {
<h2 class="section-header">Goals for {{ studentIdentifier() }}</h2>
<h2 class="section-header">Student: {{ studentIdentifier() }}</h2>
}
@if (showAddModal()) {
@@ -26,4 +29,9 @@
<app-goal-card [goal]="goal" />
}
</div>
}
}
<footer class="goal-footer">
<span class="spacer"></span>
<button class="toolbar-btn"> ⭐ Generate progress report</button>
</footer>
@@ -4,27 +4,11 @@
height: 100%;
}
.generate-report {
display: inline-flex;
align-items: center;
background-color: #4000ee;
color: white;
border: none;
padding: 8px 14px;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
}
.generate-report:hover {
background-color: #4f46e5;
}
.toolbar {
display: flex;
align-items: center;
position: relative;
gap: 0.75rem;
height: 40px;
padding-right: 0.5rem;
@@ -55,6 +39,15 @@
margin-left: 0.5rem;
}
.toolbar-title {
position: absolute;
left: 50%;
transform: translateX(-50%);
font-weight: 600;
font-size: 1.25rem;
color: #333;
}
.section-header {
font-size: 1.125rem;
font-weight: 600;
@@ -85,4 +78,14 @@
gap: 1rem;
overflow-y: auto;
flex: 1;
}
.goal-footer {
display: flex;
align-items: center;
padding: 0 1rem;
height: 48px;
background: #fff;
border-top: 1px solid #ddd;
flex-shrink: 0;
}
@@ -1,5 +1,6 @@
import { Component, inject, signal } from '@angular/core';
import { Component, inject, signal, OnDestroy } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { Subscription } from 'rxjs';
import { StudentGoalItem } from '../../../shared/classes/student-goal';
import { StudentService } from '../../../shared/services/student.service';
import { GoalCard } from '../goal-card/goal-card';
@@ -11,13 +12,15 @@ import { AddGoalModal } from '../add-goal-modal/add-goal-modal';
templateUrl: './goal-list.html',
styleUrl: './goal-list.scss',
})
export class GoalList {
export class GoalList implements OnDestroy {
// ************************** Constructor **************************
constructor() {
this.studentId = this.route.snapshot.paramMap.get('studentId')!;
this.loadGoals();
this.paramSub = this.route.paramMap.subscribe(params => {
this.studentId = params.get('studentId')!;
this.loadGoals();
});
}
// ************************** Declarations *************************
@@ -25,8 +28,9 @@ export class GoalList {
private readonly studentService = inject(StudentService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly paramSub: Subscription;
protected readonly studentId: string;
protected studentId!: string;
protected readonly studentIdentifier = signal<string | null>(null);
protected readonly goals = signal<StudentGoalItem[]>([]);
protected readonly showAddModal = signal(false);
@@ -52,7 +56,11 @@ export class GoalList {
}
onBack() {
this.router.navigate(['/students']);
this.router.navigate(['/students', this.studentId]);
}
ngOnDestroy() {
this.paramSub.unsubscribe();
}
// ********************** Support Procedures ***********************
@@ -1,13 +1,18 @@
<div class="toolbar">
<button class="toolbar-btn back-btn" (click)="onBack()">&#8592; Goals</button>
<button class="toolbar-btn back-btn" (click)="onBack()">&#8593; Goal</button>
<span class="toolbar-title">Progress Events</span>
<span class="spacer"></span>
<button class="toolbar-btn" (click)="onAddProgressEvent()">+ Add Progress Event</button>
</div>
<!-- <img class="hero-image" src="/slalomcropped.png" alt="Slalom" /> -->
@if (studentIdentifier() && goalTitle()) {
<div class="header-row">
<h2 class="section-header">
{{ events().length }} Progress Events for {{ studentIdentifier() }} for goal: {{ goalTitle() }}
Student: {{ studentIdentifier() }} &nbsp;&nbsp; Goal: {{ goalTitle() }}
@if (isFiltered()) {
<span class="filter-count">(showing {{ filteredEvents().length }})</span>
<span class="filter-count">(showing {{ filteredEvents().length }} of {{ events().length }})</span>
}
</h2>
<div class="search-box">
@@ -25,7 +30,7 @@
}
@if (filteredEvents().length === 0 && !errorMessage()) {
<p class="empty-state">No progress events recorded yet.</p>
<p class="empty-state">No progress events recorded yet. Click <strong>+ Add Progress Event</strong> to get started.</p>
} @else {
<div class="event-list">
@for (evt of filteredEvents(); track evt.progressEventId) {
@@ -4,9 +4,19 @@
height: 100%;
}
.hero-image {
max-width: 50%;
max-height: 100px;
object-fit: contain;
margin-bottom: 1rem;
flex-shrink: 0;
align-self: flex-start;
}
.toolbar {
display: flex;
align-items: center;
position: relative;
gap: 0.75rem;
height: 40px;
padding-right: 0.5rem;
@@ -17,6 +27,19 @@
flex-shrink: 0;
}
.toolbar-title {
position: absolute;
left: 50%;
transform: translateX(-50%);
font-weight: 600;
font-size: 1.25rem;
color: #333;
}
.spacer {
flex: 1;
}
.toolbar-btn {
padding: 0.375rem 0.75rem;
background: transparent;
@@ -43,6 +43,7 @@ export class ProgressList implements OnDestroy {
protected readonly errorMessage = signal<string | null>(null);
protected readonly rawSearchText = signal('');
protected readonly searchTerm = signal('');
protected readonly showAddModal = signal(false);
// ************************** Properties ***************************
@@ -65,11 +66,16 @@ export class ProgressList implements OnDestroy {
// ************************ Event Handlers *************************
onAddProgressEvent() {
this.showAddModal.set(true);
// TODO: Wire up add-progress-event modal component
}
// *****************************************************************
// Navigates back to the goals list for this student.
// Navigates back to the parent goal detail.
// *****************************************************************
onBack() {
this.router.navigate(['/students', this.studentId, 'goals']);
this.router.navigate(['/students', this.studentId, 'goals', this.goalId]);
}
// *****************************************************************
@@ -0,0 +1,18 @@
@for (node of nodes(); track node.label) {
<div class="node-row" [style.padding-left]="indent()">
@if (hasToggle(node)) {
<span class="toggle-indicator" (click)="onToggle(node, $event)">{{ node.expanded ? '' : '+' }}</span>
}
@if (node.routerLink) {
<a class="node-label" [routerLink]="node.routerLink" routerLinkActive="active"
[routerLinkActiveOptions]="{ exact: true }">{{ node.label }}</a>
} @else if (hasToggle(node)) {
<span class="node-label clickable" (click)="onToggle(node, $event)">{{ node.label }}</span>
} @else {
<span class="node-label">{{ node.label }}</span>
}
</div>
@if (node.expanded && node.children) {
<app-sidebar-tree-node [nodes]="node.children" [depth]="depth() + 1" />
}
}
@@ -0,0 +1,55 @@
:host {
display: contents;
}
.node-row {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 1rem;
min-height: 1.75rem;
}
.node-row:hover {
background: #f5f5f5;
}
.node-row:has(.node-label.active) {
background: #e5e5e5;
}
.toggle-indicator {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1rem;
height: 1rem;
border: 1px solid #aaa;
border-radius: 2px;
font-size: 0.75rem;
line-height: 1;
color: #555;
cursor: pointer;
user-select: none;
flex-shrink: 0;
}
.node-label {
flex: 1;
font-size: 0.8125rem;
color: #333;
text-decoration: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.node-label.clickable {
cursor: pointer;
user-select: none;
}
.node-label.active {
font-weight: 600;
color: #4f46e5;
}
@@ -0,0 +1,67 @@
import { Component, input } from '@angular/core';
import { RouterLink, RouterLinkActive } from '@angular/router';
import { SidebarNode } from '../../../shared/classes/sidebar-node';
@Component({
selector: 'app-sidebar-tree-node',
imports: [RouterLink, RouterLinkActive, SidebarTreeNode],
templateUrl: './sidebar-tree-node.html',
styleUrl: './sidebar-tree-node.scss',
})
export class SidebarTreeNode {
// ************************** Constructor **************************
// ************************** Declarations *************************
readonly nodes = input.required<SidebarNode[]>();
readonly depth = input<number>(0);
// ************************** Properties ***************************
// *****************************************************************
// Computed indentation in rem based on depth.
// *****************************************************************
indent(): string {
return (1 + this.depth() * 1.5) + 'rem';
}
// ************************ Public Methods *************************
// *****************************************************************
// Returns true if a node should show the +/- toggle.
// A node is expandable if it has static children, or has a
// loadChildren function with a non-zero childCount.
// *****************************************************************
hasToggle(node: SidebarNode): boolean {
if (node.children && node.children.length > 0) return true;
if (node.loadChildren && node.childCount !== 0) return true;
return false;
}
// ************************ Event Handlers *************************
// *****************************************************************
// Toggles a node's expanded state. On first expand of a lazy node,
// calls loadChildren and caches the result in node.children.
// *****************************************************************
async onToggle(node: SidebarNode, event: Event) {
if (!this.hasToggle(node)) return;
event.preventDefault();
event.stopPropagation();
if (node.expanded) {
node.expanded = false;
return;
}
if (node.loadChildren && !node.children) {
node.children = await node.loadChildren();
}
node.expanded = true;
}
// ********************** Support Procedures ***********************
}
@@ -0,0 +1,39 @@
<div class="toolbar">
<button class="toolbar-btn back-btn" (click)="onBack()">&#8593; Students</button>
<span class="toolbar-title">Student Detail</span>
<span class="spacer"></span>
</div>
@if (errorMessage()) {
<p class="error">{{ errorMessage() }}</p>
}
@if (loaded()) {
<div class="detail-card">
<div class="field">
<label class="field-label" for="identifier">Name</label>
<input id="identifier" class="field-input" type="text" [(ngModel)]="identifier" />
</div>
<div class="field">
<label class="field-label" for="expectedGrad">Expected Graduation</label>
<input id="expectedGrad" class="field-input" type="date" [(ngModel)]="expectedGradDate" />
</div>
<div class="actions">
<button class="toolbar-btn" (click)="onCancel()" [disabled]="!hasChanges()">Cancel</button>
<button class="toolbar-btn save-btn" (click)="onSave()" [disabled]="!hasChanges() || saving()">
{{ saving() ? 'Saving...' : 'Save' }}
</button>
</div>
<div class="card-footer">
<a class="detail-link" (click)="onGoals()">Goals</a>
<span class="spacer"></span>
@if (successMessage()) {
<span class="success-label" [class.fade-out]="fading()">{{ successMessage() }}</span>
}
</div>
</div>
}
@@ -0,0 +1,155 @@
:host {
display: flex;
flex-direction: column;
height: 100%;
}
.toolbar {
display: flex;
align-items: center;
position: relative;
gap: 0.75rem;
height: 40px;
padding-right: 0.5rem;
border-radius: 8px;
background: #fff;
border-bottom: 1px solid #ddd;
margin-bottom: 1rem;
flex-shrink: 0;
}
.toolbar-btn {
padding: 0.375rem 0.75rem;
background: transparent;
color: #4f46e5;
border: 1px solid #4f46e5;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
}
.toolbar-btn:hover {
background: #eef2ff;
}
.toolbar-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.back-btn {
margin-left: 0.5rem;
}
.toolbar-title {
position: absolute;
left: 50%;
transform: translateX(-50%);
font-weight: 600;
font-size: 1.25rem;
color: #333;
}
.spacer {
flex: 1;
}
.error {
font-size: 0.875rem;
color: #dc2626;
margin: 0 0 1rem;
}
.detail-card {
background: #fff;
border: 1px solid #ddd;
border-radius: 8px;
padding: 1.5rem;
max-width: 480px;
}
.field {
display: flex;
flex-direction: column;
margin-bottom: 1rem;
}
.field-label {
font-size: 0.75rem;
font-weight: 600;
color: #666;
text-transform: uppercase;
letter-spacing: 0.025em;
margin-bottom: 0.25rem;
}
.field-value {
font-size: 0.9375rem;
color: #333;
}
.field-input {
padding: 0.375rem 0.5rem;
border: 1px solid #ccc;
border-radius: 6px;
font-size: 0.9375rem;
outline: none;
}
.field-input:focus {
border-color: #4f46e5;
}
.card-footer {
display: flex;
align-items: center;
margin: 1rem -1.5rem -1rem;
padding: 0.5rem 1.5rem 0.5rem;
border-top: 1px solid #eee;
}
.detail-link {
font-size: 0.875rem;
color: #4f46e5;
cursor: pointer;
text-decoration: underline;
}
.detail-link:hover {
color: #4338ca;
}
.success-label {
font-size: 0.8125rem;
color: #16a34a;
opacity: 1;
transition: opacity 1s ease;
}
.success-label.fade-out {
opacity: 0;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.5rem;
}
.actions .toolbar-btn {
min-width: 6rem;
}
.save-btn {
background: #4f46e5;
color: #fff;
border-color: #4f46e5;
}
.save-btn:hover {
background: #4338ca;
}
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { StudentCardFull } from './student-card-full';
describe('StudentCardFull', () => {
let component: StudentCardFull;
let fixture: ComponentFixture<StudentCardFull>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [StudentCardFull]
})
.compileComponents();
fixture = TestBed.createComponent(StudentCardFull);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,160 @@
import { Component, inject, signal, OnDestroy } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { Subscription } from 'rxjs';
import { StudentService } from '../../../shared/services/student.service';
import { StudentCardDto } from '../../../shared/classes/student-card.dto';
@Component({
selector: 'app-student-card-full',
imports: [FormsModule],
templateUrl: './student-card-full.html',
styleUrl: './student-card-full.scss',
})
export class StudentCardFull implements OnDestroy {
// ************************** Constructor **************************
constructor() {
this.paramSub = this.route.paramMap.subscribe(params => {
this.studentId = params.get('studentId')!;
this.loadStudent();
});
}
// ************************** Declarations *************************
private readonly studentService = inject(StudentService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly paramSub: Subscription;
private studentId!: string;
protected readonly errorMessage = signal<string | null>(null);
protected readonly successMessage = signal<string | null>(null);
protected readonly saving = signal(false);
protected readonly loaded = signal(false);
protected readonly fading = signal(false);
private successTimer: any = null;
// Form fields — always editable
protected identifier = '';
protected expectedGradDate = '';
// Snapshot of last-saved values for cancel
private savedIdentifier = '';
private savedExpectedGradDate = '';
// ************************** Properties ***************************
// *****************************************************************
// Returns true if form values differ from the saved snapshot.
// *****************************************************************
hasChanges(): boolean {
return this.identifier !== this.savedIdentifier
|| this.expectedGradDate !== this.savedExpectedGradDate;
}
// ************************ Public Methods *************************
// ************************ Event Handlers *************************
// *****************************************************************
// Saves changes to the student via the API.
// *****************************************************************
async onSave() {
this.saving.set(true);
this.errorMessage.set(null);
this.successMessage.set(null);
const result = await this.studentService.updateStudent(this.studentId, {
identifier: this.identifier,
expectedGrad: this.expectedGradDate || null,
});
this.saving.set(false);
if (result.success) {
this.savedIdentifier = this.identifier;
this.savedExpectedGradDate = this.expectedGradDate;
this.showSuccessTemporarily('Changes saved.');
this.studentService.notifyDataChanged();
} else {
this.errorMessage.set(result.message);
}
}
// *****************************************************************
// Reverts form fields to the last-saved snapshot.
// *****************************************************************
onCancel() {
this.identifier = this.savedIdentifier;
this.expectedGradDate = this.savedExpectedGradDate;
this.errorMessage.set(null);
this.successMessage.set(null);
}
onBack() {
this.router.navigate(['/students']);
}
onGoals() {
this.router.navigate(['/students', this.studentId, 'goals']);
}
ngOnDestroy() {
this.paramSub.unsubscribe();
if (this.successTimer) clearTimeout(this.successTimer);
}
// ********************** Support Procedures ***********************
// *****************************************************************
// Shows a success message for 4 seconds, then fades it out over 1s.
// *****************************************************************
private showSuccessTemporarily(message: string) {
if (this.successTimer) clearTimeout(this.successTimer);
this.fading.set(false);
this.successMessage.set(message);
this.successTimer = setTimeout(() => {
this.fading.set(true);
this.successTimer = setTimeout(() => {
this.successMessage.set(null);
this.fading.set(false);
}, 1000);
}, 4000);
}
// *****************************************************************
// Loads the student by ID and populates form fields.
// *****************************************************************
private loadStudent() {
if (!this.loaded()) {
this.loaded.set(false);
}
this.studentService.getStudentById(this.studentId).then(result => {
if (result.success && result.payload) {
const s = result.payload;
this.identifier = s.identifier;
this.expectedGradDate = this.toDateInput(s.expectedGradDate);
this.savedIdentifier = this.identifier;
this.savedExpectedGradDate = this.expectedGradDate;
this.loaded.set(true);
} else {
this.errorMessage.set(result.message);
}
});
}
// *****************************************************************
// Converts a Date to a YYYY-MM-DD string for date input binding.
// *****************************************************************
private toDateInput(date: Date | null): string {
if (!date) return '';
const d = new Date(date);
return d.toISOString().split('T')[0];
}
}
@@ -16,10 +16,11 @@
margin-bottom: 1rem;
flex-shrink: 0;
}
.page-title {
font-size: 24px;
font-weight: 600;
margin: 0;
margin-left: 0.75rem;
}
.toolbar-btn {
@@ -40,8 +41,8 @@
.card-grid {
display: flex;
flex-wrap: wrap;
align-content: flex-start;
gap: 1rem;
overflow-y: auto;
flex: 1;
}
}
@@ -41,7 +41,7 @@ export class StudentCardList {
}
onStudentCreated(student: StudentCardDto) {
this.students.update(list => [...list, student]);
this.students.update(list => this.sortByIdentifier([...list, student]));
this.showAddModal.set(false);
}
@@ -51,19 +51,26 @@ export class StudentCardList {
// ********************** Support Procedures ***********************
// *****************************************************************
// Sorts an array of students alphabetically by identifier.
// *****************************************************************
private sortByIdentifier(students: StudentCardDto[]): StudentCardDto[] {
return students.sort((a, b) =>
a.identifier.localeCompare(b.identifier, undefined, { sensitivity: 'base' })
);
}
// *****************************************************************
// Loads students from the service and populates the students signal.
// *****************************************************************
private loadStudents() {
this.studentService.getMyStudents().then(data => {
if(!data.success)
{
if (!data.success) {
this.errorMessage.set(data.message);
}
else
{
this.students.set(data.payload || [])
else {
this.students.set(this.sortByIdentifier(data.payload || []))
}
});
@@ -1,4 +1,4 @@
<div class="card" [routerLink]="['/students', student().studentId, 'goals']">
<div class="card" [routerLink]="['/students', student().studentId]">
<h2 class="identifier">🎓 {{ student().identifier }}</h2>
<div class="meta">