Desktop Update

This commit is contained in:
ivan-pelly
2026-03-03 19:35:54 -08:00
parent 9535e61876
commit 6e73012430
17 changed files with 531 additions and 41 deletions
@@ -1,4 +1,4 @@
<div class="card">
<div class="card clickable" (click)="onCardClick()">
<div class="card-header">
<span class="category-badge">{{ goal().category }}</span>
<span class="event-count">{{ goal().progressEventCount }} events</span>
@@ -6,4 +6,4 @@
<h3 class="title">{{ goal().title }}</h3>
<p class="description">{{ goal().description }}</p>
</div>
</div>
@@ -11,6 +11,16 @@
display: flex;
flex-direction: column;
gap: 0.625rem;
height: 130px;
min-width: 0;
}
.card.clickable {
cursor: pointer;
}
.card.clickable:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
}
.card-header {
@@ -38,6 +48,9 @@
font-size: 1rem;
font-weight: 600;
color: #111;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.description {
@@ -45,4 +58,8 @@
font-size: 0.875rem;
color: #555;
line-height: 1.5;
}
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
@@ -1,4 +1,5 @@
import { Component, input } from '@angular/core';
import { Component, inject, input } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { StudentGoalItem } from '../../../shared/classes/student-goal';
@Component({
@@ -13,6 +14,9 @@ export class GoalCard {
// ************************** Declarations *************************
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
readonly goal = input.required<StudentGoalItem>();
// ************************** Properties ***************************
@@ -21,5 +25,13 @@ export class GoalCard {
// ************************ Event Handlers *************************
// *****************************************************************
// Navigates to the progress events page for this goal.
// *****************************************************************
onCardClick() {
const studentId = this.route.snapshot.paramMap.get('studentId')!;
this.router.navigate(['/students', studentId, 'goals', this.goal().goalId, 'progress']);
}
// ********************** Support Procedures ***********************
}
@@ -1,31 +1,28 @@
<div class="toolbar">
<button class="toolbar-btn back-btn" (click)="onBack()">&#8592; Students</button>
@if (studentIdentifier()) {
<span class="student-label">{{ studentIdentifier() }}</span>
}
<span class="spacer"></span>
<button class="toolbar-btn" (click)="onAddGoal()">+ Add a Goal</button>
</div>
@if (studentIdentifier()) {
<h2 class="section-header">Goals for {{ studentIdentifier() }}</h2>
}
@if (showAddModal()) {
<app-add-goal-modal
[studentId]="studentId"
[existingGoals]="goals()"
(goalCreated)="onGoalCreated($event)"
(cancelled)="onModalCancelled()"
/>
<app-add-goal-modal [studentId]="studentId" [existingGoals]="goals()" (goalCreated)="onGoalCreated($event)"
(cancelled)="onModalCancelled()" />
}
@if (errorMessage()) {
<p class="error">{{ errorMessage() }}</p>
<p class="error">{{ errorMessage() }}</p>
}
@if (goals().length === 0 && !errorMessage()) {
<p class="empty-state">No goals yet. Click <strong>+ Add a Goal</strong> to get started.</p>
<p class="empty-state">No goals yet. Click <strong>+ Add a Goal</strong> to get started.</p>
} @else {
<div class="card-grid">
@for (goal of goals(); track goal.goalId) {
<app-goal-card [goal]="goal" />
}
</div>
}
<div class="card-grid">
@for (goal of goals(); track goal.goalId) {
<app-goal-card [goal]="goal" />
}
</div>
}
@@ -36,10 +36,11 @@
margin-left: 0.5rem;
}
.student-label {
font-size: 0.9375rem;
.section-header {
font-size: 1.125rem;
font-weight: 600;
color: #333;
margin: 0 0 0.5rem;
}
.spacer {
@@ -65,4 +66,4 @@
gap: 1rem;
overflow-y: auto;
flex: 1;
}
}
@@ -0,0 +1,9 @@
<div class="card">
<p class="content">{{ event().content }}</p>
<div class="action-icons">
<button class="icon-btn" title="Edit">&#9998;</button>
<button class="icon-btn" title="Delete">&#128465;</button>
</div>
<span class="author">{{ event().createdByName }}</span>
<span class="date">{{ event().createdAt | date:'MMM d, y' }}</span>
</div>
@@ -0,0 +1,61 @@
:host {
display: block;
}
.card {
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
padding: 1.25rem 1.5rem;
display: grid;
grid-template-columns: 1fr auto;
grid-template-rows: auto auto;
gap: 0.75rem 1rem;
}
.content {
grid-column: 1;
grid-row: 1;
margin: 0;
font-size: 0.9375rem;
color: #333;
line-height: 1.6;
}
.action-icons {
grid-column: 2;
grid-row: 1;
display: flex;
gap: 0.5rem;
align-self: start;
justify-content: flex-end;
}
.author {
grid-column: 1;
grid-row: 2;
font-size: 0.8125rem;
font-weight: 600;
color: #888;
}
.date {
grid-column: 2;
grid-row: 2;
font-size: 0.8125rem;
color: #888;
text-align: right;
}
.icon-btn {
background: none;
border: none;
cursor: pointer;
font-size: 1rem;
color: #888;
padding: 0.125rem;
}
.icon-btn:hover {
color: #4f46e5;
}
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ProgressItem } from './progress-item';
describe('ProgressItem', () => {
let component: ProgressItem;
let fixture: ComponentFixture<ProgressItem>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ProgressItem]
})
.compileComponents();
fixture = TestBed.createComponent(ProgressItem);
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 { ProgressEventDto } from '../../../shared/classes/progress-event.dto';
@Component({
selector: 'app-progress-item',
imports: [DatePipe],
templateUrl: './progress-item.html',
styleUrl: './progress-item.scss',
})
export class ProgressItem {
// ************************** Constructor **************************
// ************************** Declarations *************************
readonly event = input.required<ProgressEventDto>();
// ************************** Properties ***************************
// ************************ Public Methods *************************
// ************************ Event Handlers *************************
// ********************** Support Procedures ***********************
}
@@ -0,0 +1,35 @@
<div class="toolbar">
<button class="toolbar-btn back-btn" (click)="onBack()">&#8592; Goals</button>
</div>
@if (studentIdentifier() && goalTitle()) {
<div class="header-row">
<h2 class="section-header">
{{ events().length }} Progress Events for {{ studentIdentifier() }} for goal: {{ goalTitle() }}
@if (isFiltered()) {
<span class="filter-count">(showing {{ filteredEvents().length }})</span>
}
</h2>
<div class="search-box">
<input type="text" class="search-input" placeholder="Search..." [value]="rawSearchText()"
(input)="onSearchInput($any($event.target).value)" />
@if (rawSearchText()) {
<button class="clear-btn" (click)="onClearSearch()">&times;</button>
}
</div>
</div>
}
@if (errorMessage()) {
<p class="error">{{ errorMessage() }}</p>
}
@if (filteredEvents().length === 0 && !errorMessage()) {
<p class="empty-state">No progress events recorded yet.</p>
} @else {
<div class="event-list">
@for (evt of filteredEvents(); track evt.progressEventId) {
<app-progress-item [event]="evt" />
}
</div>
}
@@ -0,0 +1,116 @@
: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;
}
.header-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
padding-right: calc(0.75rem + 17px);
flex-shrink: 0;
}
.section-header {
font-size: 1.125rem;
font-weight: 600;
color: #333;
margin: 0;
}
.filter-count {
font-weight: 400;
color: #888;
}
.search-box {
position: relative;
}
.search-input {
padding: 0.375rem 2rem 0.375rem 0.75rem;
border: 1px solid #ccc;
border-radius: 6px;
font-size: 0.875rem;
width: 200px;
outline: none;
}
.search-input:focus {
border-color: #4f46e5;
}
.clear-btn {
position: absolute;
right: 0.375rem;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
cursor: pointer;
font-size: 1.125rem;
color: #888;
line-height: 1;
padding: 0 0.125rem;
}
.clear-btn:hover {
color: #333;
}
.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;
}
.event-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
overflow-y: auto;
scrollbar-gutter: stable;
flex: 1;
padding-right: 0.75rem;
}
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ProgressList } from './progress-list';
describe('ProgressList', () => {
let component: ProgressList;
let fixture: ComponentFixture<ProgressList>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ProgressList]
})
.compileComponents();
fixture = TestBed.createComponent(ProgressList);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,130 @@
import { Component, computed, inject, signal, OnDestroy } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { Subject } from 'rxjs';
import { debounceTime } from 'rxjs/operators';
import { ProgressItem } from '../progress-item/progress-item';
import { ProgressEventDto } from '../../../shared/classes/progress-event.dto';
import { DummyStudentService } from '../../../shared/services/dummy-student.service';
import { StudentService } from '../../../shared/services/student.service';
@Component({
selector: 'app-progress-list',
imports: [ProgressItem],
templateUrl: './progress-list.html',
styleUrl: './progress-list.scss',
})
export class ProgressList implements OnDestroy {
// ************************** Constructor **************************
constructor() {
this.studentId = this.route.snapshot.paramMap.get('studentId')!;
this.goalId = this.route.snapshot.paramMap.get('goalId')!;
this.loadEvents();
this.loadGoalTitle();
this.searchInput$.pipe(debounceTime(300)).subscribe(term => {
this.searchTerm.set(term);
});
}
// ************************** Declarations *************************
private readonly dummyService = inject(DummyStudentService);
private readonly studentService = inject(StudentService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly studentId: string;
private readonly goalId: string;
private readonly searchInput$ = new Subject<string>();
protected readonly studentIdentifier = signal<string | null>(null);
protected readonly goalTitle = signal<string | null>(null);
protected readonly events = signal<ProgressEventDto[]>([]);
protected readonly errorMessage = signal<string | null>(null);
protected readonly rawSearchText = signal('');
protected readonly searchTerm = signal('');
// ************************** Properties ***************************
// *****************************************************************
// Returns events filtered by the debounced search term. Matches
// against the event content (case-insensitive). Only filters when
// the term is at least 2 characters.
// *****************************************************************
protected readonly filteredEvents = computed(() => {
const term = this.searchTerm().trim().toLowerCase();
if (term.length < 2) return this.events();
return this.events().filter(e => e.content.toLowerCase().includes(term));
});
protected readonly isFiltered = computed(() => {
return this.searchTerm().trim().length >= 2 && this.filteredEvents().length !== this.events().length;
});
// ************************ Public Methods *************************
// ************************ Event Handlers *************************
// *****************************************************************
// Navigates back to the goals list for this student.
// *****************************************************************
onBack() {
this.router.navigate(['/students', this.studentId, 'goals']);
}
// *****************************************************************
// Pushes the raw input value into the debounce stream.
// *****************************************************************
onSearchInput(value: string) {
this.rawSearchText.set(value);
this.searchInput$.next(value);
}
// *****************************************************************
// Clears the search box and resets the filter.
// *****************************************************************
onClearSearch() {
this.rawSearchText.set('');
this.searchTerm.set('');
}
ngOnDestroy() {
this.searchInput$.complete();
}
// ********************** Support Procedures ***********************
// *****************************************************************
// Loads progress events for the given goal from the dummy service,
// sorted newest-first by createdAt.
// TODO: Replace DummyStudentService with StudentService
// *****************************************************************
private loadEvents() {
this.dummyService.getProgressEventsForGoal(this.goalId).then(result => {
if (!result.success) {
this.errorMessage.set(result.message);
} else {
const sorted = (result.payload ?? [])
.slice()
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
this.events.set(sorted);
}
});
}
// *****************************************************************
// Loads the goal title from the student's goal list so the heading
// can display "Progress for <goal title>".
// *****************************************************************
private loadGoalTitle() {
this.studentService.getGoalsForStudent(this.studentId).then(result => {
if (result.success && result.payload) {
this.studentIdentifier.set(result.payload.studentIdentifier);
const goal = result.payload.goals.find(g => g.goalId === this.goalId);
this.goalTitle.set(goal?.title ?? null);
}
});
}
}