This commit is contained in:
ivan-pelly
2026-03-01 18:21:51 -08:00
parent d9a7b6c21e
commit 41e7120012
35 changed files with 1194 additions and 97 deletions
+1
View File
@@ -7,3 +7,4 @@
/.vs/WinStudentGoalTracker/FileContentIndex /.vs/WinStudentGoalTracker/FileContentIndex
/.vs/WinStudentGoalTracker/DesignTimeBuild /.vs/WinStudentGoalTracker/DesignTimeBuild
/.vs/WinStudentGoalTracker/v17 /.vs/WinStudentGoalTracker/v17
/.vs
@@ -2,16 +2,19 @@ import { inject } from '@angular/core';
import { Routes } from '@angular/router'; import { Routes } from '@angular/router';
import { Login } from './shared/pages/login/login'; import { Login } from './shared/pages/login/login';
import { PlatformService } from './shared/services/platform.service'; import { PlatformService } from './shared/services/platform.service';
import { authGuard } from './shared/guards/auth.guard';
export const routes: Routes = [ export const routes: Routes = [
{ path: 'login', component: Login }, { path: 'login', component: Login },
{ {
path: '', path: '',
canMatch: [() => inject(PlatformService).formFactor() === 'mobile'], canMatch: [() => inject(PlatformService).formFactor() === 'mobile'],
canActivate: [authGuard],
loadChildren: () => import('./mobile/mobile.routes'), loadChildren: () => import('./mobile/mobile.routes'),
}, },
{ {
path: '', path: '',
canActivate: [authGuard],
loadChildren: () => import('./desktop/desktop.routes'), loadChildren: () => import('./desktop/desktop.routes'),
}, },
]; ];
@@ -3,6 +3,8 @@
<header class="header"> <header class="header">
<button class="menu-toggle" (click)="onToggleSidebar()"></button> <button class="menu-toggle" (click)="onToggleSidebar()"></button>
<span class="header-title">WinStudentGoalTracker</span> <span class="header-title">WinStudentGoalTracker</span>
<span class="spacer"></span>
<button class="logout-btn" (click)="onLogout()">Log Out</button>
</header> </header>
<!-- Body: Sidebar + Main --> <!-- Body: Sidebar + Main -->
@@ -42,6 +42,24 @@
color: #333; color: #333;
} }
.spacer {
flex: 1;
}
.logout-btn {
background: none;
border: 1px solid #ddd;
border-radius: 6px;
padding: 0.25rem 0.75rem;
font-size: 0.8125rem;
color: #333;
cursor: pointer;
}
.logout-btn:hover {
background: #f5f5f5;
}
/* Body: Sidebar + Content */ /* Body: Sidebar + Content */
.body { .body {
display: flex; display: flex;
@@ -1,5 +1,6 @@
import { Component, signal } from '@angular/core'; import { Component, inject, signal } from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { Auth } from '../../../shared/services/auth';
@Component({ @Component({
selector: 'app-home', selector: 'app-home',
@@ -13,6 +14,7 @@ export class Home {
// ************************** Declarations ************************* // ************************** Declarations *************************
private readonly auth = inject(Auth);
protected readonly sidebarExpanded = signal(false); protected readonly sidebarExpanded = signal(false);
// ************************** Properties *************************** // ************************** Properties ***************************
@@ -25,5 +27,13 @@ export class Home {
this.sidebarExpanded.update(v => !v); this.sidebarExpanded.update(v => !v);
} }
// *****************************************************************
// Logs the user out and sends them back to the login screen.
// *****************************************************************
onLogout() {
this.auth.logout().subscribe();
this.auth.forceLogout();
}
// ********************** Support Procedures *********************** // ********************** Support Procedures ***********************
} }
@@ -0,0 +1,8 @@
<div class="card" (click)="onCardClick()">
<h2 class="identifier">{{ student().identifier }}</h2>
<span class="age-badge">Age: {{ student().age }}</span>
<div class="stats">
<span class="stat">{{ student().goalCount }} goals</span>
<span class="stat">{{ student().progressEventCount }} events</span>
</div>
</div>
@@ -0,0 +1,44 @@
:host {
display: block;
}
.card {
display: flex;
flex-direction: column;
align-items: center;
padding: 1.25rem 1rem;
background: #fff;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.07);
cursor: pointer;
}
.card:active {
background: #f9f9f9;
}
.identifier {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
color: #333;
}
.age-badge {
display: inline-block;
margin-top: 0.5rem;
padding: 0.2rem 0.75rem;
font-size: 0.8125rem;
color: #555;
background: #f0f0f0;
border-radius: 12px;
}
.stats {
display: flex;
gap: 1rem;
margin-top: 0.75rem;
font-size: 0.8125rem;
font-weight: 500;
color: #555;
}
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { StudentCard } from './student-card';
describe('StudentCard', () => {
let component: StudentCard;
let fixture: ComponentFixture<StudentCard>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [StudentCard]
})
.compileComponents();
fixture = TestBed.createComponent(StudentCard);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,34 @@
import { Component, inject, input } from '@angular/core';
import { Router } from '@angular/router';
import { StudentCardDto } from '../../../shared/models/dto/student-card.dto';
@Component({
selector: 'app-student-card',
imports: [],
templateUrl: './student-card.html',
styleUrl: './student-card.scss',
})
export class StudentCard {
// ************************** Constructor **************************
// ************************** Declarations *************************
private readonly router = inject(Router);
readonly student = input.required<StudentCardDto>();
// ************************** Properties ***************************
// ************************ Public Methods *************************
// ************************ Event Handlers *************************
// *****************************************************************
// Navigates to the goals page for this student.
// *****************************************************************
onCardClick() {
this.router.navigate(['students', this.student().studentId, 'goals']);
}
// ********************** Support Procedures ***********************
}
@@ -1,6 +1,18 @@
import { Routes } from '@angular/router'; import { Routes } from '@angular/router';
import { Home } from './pages/home/home'; import { Home } from './pages/home/home';
import { Students } from './pages/students/students';
import { StudentGoals } from './pages/student-goals/student-goals';
import { AddProgressEvent } from './pages/add-progress-event/add-progress-event';
export default [ export default [
{ path: '', component: Home }, {
path: '',
component: Home,
children: [
{ path: '', redirectTo: 'students', pathMatch: 'full' },
{ path: 'students', component: Students },
{ path: 'students/:studentId/goals', component: StudentGoals },
{ path: 'students/:studentId/goals/:goalId/add-event', component: AddProgressEvent },
],
},
] satisfies Routes; ] satisfies Routes;
@@ -0,0 +1,27 @@
<div class="event-page">
<!-- Header -->
<div class="page-header">
<button class="back-btn" (click)="onBack()">← Back</button>
<span class="student-name">{{ studentIdentifier() }}</span>
</div>
<h2 class="goal-title">{{ goalTitle() }}</h2>
<!-- Error message -->
@if (error()) {
<p class="error">{{ error() }}</p>
}
<!-- Progress event form -->
<div class="form-card">
<label class="field-label">Progress event notes</label>
<textarea class="notes-input" placeholder="Type your message here." rows="5" [(ngModel)]="notes"></textarea>
<label class="field-label">Voice note</label>
<button class="voice-btn">🎙 Record voice note</button>
</div>
<!-- Save button -->
<button class="save-btn" [disabled]="!canSave()" (click)="onSave()">
{{ saving() ? 'Saving...' : 'Save' }}
</button>
</div>
@@ -0,0 +1,132 @@
:host {
display: flex;
flex-direction: column;
height: 100%;
}
.page-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1.25rem;
}
.event-page {
display: flex;
flex-direction: column;
flex: 1;
}
.back-btn {
background: none;
border: 1px solid #ddd;
border-radius: 16px;
color: #333;
font-size: 0.9375rem;
font-weight: 500;
cursor: pointer;
padding: 0.5rem 1rem;
flex-shrink: 0;
}
.back-btn:active {
background: #f5f5f5;
}
.student-name {
margin-left: auto;
font-size: 1.5rem;
font-weight: 700;
color: #333;
}
.goal-title {
margin: 0 0 1rem;
font-size: 1rem;
font-weight: 500;
color: #555;
}
/* Form card */
.form-card {
background: #fff;
border: 1px solid #e5e5e5;
border-radius: 10px;
padding: 1.25rem;
margin-bottom: 1.25rem;
}
.field-label {
display: block;
font-size: 0.875rem;
font-weight: 600;
color: #333;
margin-bottom: 0.5rem;
}
.notes-input {
width: 100%;
border: 1px solid #ddd;
border-radius: 8px;
padding: 0.75rem;
font-size: 0.875rem;
font-family: inherit;
resize: vertical;
margin-bottom: 1rem;
box-sizing: border-box;
}
.notes-input:focus {
outline: none;
border-color: #4f46e5;
}
.voice-btn {
display: block;
width: 100%;
padding: 0.625rem;
background: #f5f5f5;
border: 1px solid #ddd;
border-radius: 20px;
font-size: 0.875rem;
color: #333;
cursor: pointer;
text-align: center;
}
.voice-btn:active {
background: #eee;
}
/* Save button */
.save-btn {
display: block;
width: 100%;
padding: 0.875rem;
background: #222;
color: #fff;
border: none;
border-radius: 10px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
margin-top: auto;
}
.save-btn:active {
background: #444;
}
.save-btn:disabled {
background: #ccc;
cursor: default;
}
.error {
margin: 0 0 1rem;
padding: 0.75rem 1rem;
background: #fef2f2;
color: #dc2626;
border-radius: 8px;
font-size: 0.875rem;
}
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AddProgressEvent } from './add-progress-event';
describe('AddProgressEvent', () => {
let component: AddProgressEvent;
let fixture: ComponentFixture<AddProgressEvent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AddProgressEvent]
})
.compileComponents();
fixture = TestBed.createComponent(AddProgressEvent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,85 @@
import { Component, computed, inject, signal } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { HttpErrorResponse } from '@angular/common/http';
import { DummySaveProgressEvent } from '../../../shared/services/dummy-save-progress-event.service';
import { describeHttpError } from '../../../shared/classes/http-errors';
@Component({
selector: 'app-add-progress-event',
imports: [FormsModule],
templateUrl: './add-progress-event.html',
styleUrl: './add-progress-event.scss',
})
export class AddProgressEvent {
// ************************** Constructor **************************
constructor() {
this.goalTitle.set(this.route.snapshot.queryParamMap.get('goalTitle') ?? '');
this.studentIdentifier.set(this.route.snapshot.queryParamMap.get('studentIdentifier') ?? '');
this.studentId = this.route.snapshot.paramMap.get('studentId') ?? '';
this.goalId = this.route.snapshot.paramMap.get('goalId') ?? '';
}
// ************************** Declarations *************************
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly saveService = inject(DummySaveProgressEvent);
private readonly studentId: string;
private readonly goalId: string;
protected readonly goalTitle = signal('');
protected readonly studentIdentifier = signal('');
protected readonly notes = signal('');
protected readonly error = signal<string | null>(null);
protected readonly saving = signal(false);
// ************************** Properties ***************************
// *****************************************************************
// True when there is content to save.
// *****************************************************************
protected readonly canSave = computed(() =>
this.notes().trim().length > 0 && !this.saving(),
);
// ************************ Public Methods *************************
// ************************ Event Handlers *************************
// *****************************************************************
// Navigates back to the student's goal list.
// *****************************************************************
onBack() {
this.router.navigate(['students', this.studentId, 'goals']);
}
// *****************************************************************
// Saves the progress event. On success, returns to the goal list.
// On failure, displays the error message from the API.
// *****************************************************************
onSave() {
this.error.set(null);
this.saving.set(true);
this.saveService.save(this.studentId, this.goalId, this.notes().trim()).subscribe({
next: (result) => {
this.saving.set(false);
if (result.success) {
this.router.navigate(['students', this.studentId, 'goals']);
} else {
this.error.set(result.message);
}
},
error: (err: HttpErrorResponse) => {
this.saving.set(false);
this.error.set(describeHttpError(err));
},
});
}
// ********************** Support Procedures ***********************
}
@@ -1 +1,17 @@
<p>Mobile home works!</p> <div class="page">
<!-- Header: Program Name -->
<header class="header">
<h1 class="program-name">{{ meta()?.programName }}</h1>
</header>
<!-- Content: Child routes render here -->
<main class="content">
<router-outlet />
</main>
<!-- Sticky Footer: User Name + Logout -->
<footer class="footer">
<span class="user-name">{{ meta()?.userName }}</span>
<button class="logout-btn" (click)="onLogout()">Log Out</button>
</footer>
</div>
@@ -0,0 +1,65 @@
:host {
display: block;
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.page {
display: flex;
flex-direction: column;
height: 100vh;
background: #f5f5f5;
}
/* Header */
.header {
padding: 1rem;
background: #fff;
border-bottom: 1px solid #eee;
flex-shrink: 0;
}
.program-name {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: #333;
}
/* Content */
.content {
flex: 1;
overflow-y: auto;
padding: 1rem;
}
/* Sticky Footer */
.footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: #fff;
border-top: 1px solid #eee;
flex-shrink: 0;
}
.user-name {
font-size: 0.875rem;
font-weight: 500;
color: #333;
}
.logout-btn {
background: none;
border: 1px solid #ddd;
border-radius: 6px;
padding: 0.375rem 0.75rem;
font-size: 0.8125rem;
color: #333;
cursor: pointer;
}
.logout-btn:active {
background: #f5f5f5;
}
@@ -1,11 +1,48 @@
import { Component } from '@angular/core'; import { Component, inject, signal } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { DummyMobileHomeMeta, MobileHomeMeta } from '../../../shared/services/dummy-mobile-home-meta.service';
import { Auth } from '../../../shared/services/auth';
@Component({ @Component({
selector: 'app-home', selector: 'app-home',
imports: [], imports: [RouterOutlet],
templateUrl: './home.html', templateUrl: './home.html',
styleUrl: './home.scss', styleUrl: './home.scss',
}) })
export class Home { export class Home {
// ************************** Constructor **************************
constructor() {
this.loadMeta();
}
// ************************** Declarations *************************
private readonly metaService = inject(DummyMobileHomeMeta);
private readonly auth = inject(Auth);
protected readonly meta = signal<MobileHomeMeta | null>(null);
// ************************** Properties ***************************
// ************************ Public Methods *************************
// ************************ Event Handlers *************************
// *****************************************************************
// Logs the user out and sends them back to the login screen.
// *****************************************************************
onLogout() {
this.auth.logout().subscribe();
this.auth.forceLogout();
}
// ********************** Support Procedures ***********************
private loadMeta() {
this.metaService.getMeta().subscribe(data => {
this.meta.set(data);
});
}
} }
@@ -0,0 +1,21 @@
<div class="goals-page">
<!-- Header with back button and student name -->
<div class="page-header">
<button class="back-btn" (click)="onBack()">← Back</button>
<h1 class="student-name">{{ data()?.studentIdentifier }}</h1>
</div>
<!-- Goal cards -->
<h2 class="section-heading">Goals</h2>
<div class="goal-cards">
@for (goal of data()?.goals; track goal.goalId) {
<div class="goal-card" (click)="onGoalClick(goal.goalId, goal.title)">
<span class="goal-title">{{ goal.title }}</span>
<span class="event-count">{{ goal.progressEventCount }}</span>
</div>
}
@empty {
<div class="empty-state">No goals yet.</div>
}
</div>
</div>
@@ -0,0 +1,93 @@
:host {
display: block;
}
.page-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1.25rem;
}
.back-btn {
background: none;
border: 1px solid #ddd;
border-radius: 16px;
color: #333;
font-size: 0.9375rem;
font-weight: 500;
cursor: pointer;
padding: 0.5rem 1rem;
flex-shrink: 0;
}
.back-btn:active {
background: #f5f5f5;
}
.student-name {
margin: 0;
margin-left: auto;
font-size: 1.5rem;
font-weight: 700;
color: #333;
}
/* Goal cards */
.section-heading {
margin: 0 0 0.75rem;
font-size: 1rem;
font-weight: 600;
color: #333;
}
.goal-cards {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.goal-card {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.25rem;
background: #fff;
border: 1px solid #e5e5e5;
border-radius: 10px;
cursor: pointer;
}
.goal-card:active {
background: #f9f9f9;
}
.goal-title {
font-size: 1rem;
font-weight: 500;
color: #333;
}
.event-count {
flex-shrink: 0;
min-width: 1.75rem;
height: 1.75rem;
display: flex;
align-items: center;
justify-content: center;
background: #f0f0f0;
border-radius: 50%;
font-size: 0.8125rem;
font-weight: 600;
color: #555;
}
.empty-state {
padding: 1.5rem 1rem;
text-align: center;
color: #888;
font-size: 0.875rem;
background: #fff;
border-radius: 10px;
border: 1px solid #e5e5e5;
}
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { StudentGoals } from './student-goals';
describe('StudentGoals', () => {
let component: StudentGoals;
let fixture: ComponentFixture<StudentGoals>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [StudentGoals]
})
.compileComponents();
fixture = TestBed.createComponent(StudentGoals);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,63 @@
import { Component, inject, signal } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { DummyStudentGoalService, StudentGoalSummary } from '../../../shared/services/dummy-student-goal.service';
@Component({
selector: 'app-student-goals',
imports: [],
templateUrl: './student-goals.html',
styleUrl: './student-goals.scss',
})
export class StudentGoals {
// ************************** Constructor **************************
constructor() {
this.loadGoals();
}
// ************************** Declarations *************************
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly goalService = inject(DummyStudentGoalService);
private readonly studentId = this.route.snapshot.paramMap.get('studentId') ?? '';
protected readonly data = signal<StudentGoalSummary | null>(null);
// ************************** Properties ***************************
// ************************ Public Methods *************************
// ************************ Event Handlers *************************
// *****************************************************************
// Navigates back to the student list.
// *****************************************************************
onBack() {
this.router.navigate(['students'], { relativeTo: this.route.parent });
}
// *****************************************************************
// Navigates to the add-progress-event page for the selected goal.
// *****************************************************************
onGoalClick(goalId: string, goalTitle: string) {
this.router.navigate(
['students', this.studentId, 'goals', goalId, 'add-event'],
{ queryParams: { goalTitle, studentIdentifier: this.data()?.studentIdentifier } },
);
}
// ********************** Support Procedures ***********************
// *****************************************************************
// Reads the student ID from the route param and loads their goals.
// *****************************************************************
private loadGoals() {
if (!this.studentId) return;
this.goalService.getGoalsForStudent(this.studentId).subscribe(result => {
this.data.set(result);
});
}
}
@@ -0,0 +1,5 @@
<div class="card-grid">
@for (student of students(); track student.studentId) {
<app-student-card [student]="student" />
}
</div>
@@ -0,0 +1,9 @@
:host {
display: block;
}
.card-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
}
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Students } from './students';
describe('Students', () => {
let component: Students;
let fixture: ComponentFixture<Students>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Students]
})
.compileComponents();
fixture = TestBed.createComponent(Students);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,41 @@
import { Component, inject, signal } from '@angular/core';
import { StudentCard } from '../../components/student-card/student-card';
import { StudentService } from '../../../shared/services/student.service';
import { StudentCardDto } from '../../../shared/models/dto/student-card.dto';
@Component({
selector: 'app-students',
imports: [StudentCard],
templateUrl: './students.html',
styleUrl: './students.scss',
})
export class Students {
// ************************** Constructor **************************
constructor() {
this.loadStudents();
}
// ************************** Declarations *************************
private readonly studentService = inject(StudentService);
protected readonly students = signal<StudentCardDto[]>([]);
// ************************** Properties ***************************
// ************************ Public Methods *************************
// ************************ Event Handlers *************************
// ********************** Support Procedures ***********************
// *****************************************************************
// Loads the list of students assigned to the current user.
// *****************************************************************
private loadStudents() {
this.studentService.getDummyStudentsForUser().subscribe(data => {
this.students.set(data);
});
}
}
@@ -0,0 +1,37 @@
// *****************************************************************
// Standard wrapper for API responses. On success, the payload
// contains the returned data. On failure, message contains the
// error description from the server.
// *****************************************************************
export class ApiResult<T = void> {
success: boolean;
payload: T | null;
message: string;
private constructor(success: boolean, payload: T | null, message: string) {
this.success = success;
this.payload = payload;
this.message = message;
}
// *****************************************************************
// Creates a successful result with the given payload.
// *****************************************************************
static ok<T>(payload: T): ApiResult<T> {
return new ApiResult<T>(true, payload, '');
}
// *****************************************************************
// Creates a successful result with no payload.
// *****************************************************************
static empty(): ApiResult<void> {
return new ApiResult<void>(true, null, '');
}
// *****************************************************************
// Creates a failed result with the given error message.
// *****************************************************************
static fail<T = void>(message: string): ApiResult<T> {
return new ApiResult<T>(false, null, message);
}
}
@@ -0,0 +1,31 @@
import { HttpErrorResponse } from '@angular/common/http';
// *****************************************************************
// Maps an HttpErrorResponse to a user-friendly diagnostic message.
// Status 0 means the browser never received a response (API
// unreachable, DNS failure, CORS block, etc.). 5xx errors typically
// indicate a backend issue such as a database connection failure.
// *****************************************************************
export function describeHttpError(error: HttpErrorResponse): string {
// Try to extract a message from the response body first.
const serverMessage = error.error?.message ?? error.error?.Message;
switch (error.status) {
case 0:
return 'Unable to reach the server. Check that the API is running and accessible.';
case 400:
return serverMessage ?? 'Bad request (400).';
case 401:
return serverMessage ?? 'Not authorized (401).';
case 403:
return serverMessage ?? 'Access denied (403).';
case 404:
return 'Endpoint not found (404). The API may be running a different version.';
case 500:
return 'Server error (500). The API encountered an internal failure (possibly a database issue).';
case 503:
return 'Server unavailable (503). The API may be starting up or overwhelmed.';
default:
return `Unexpected error (${error.status}).`;
}
}
@@ -0,0 +1,19 @@
import { inject } from '@angular/core';
import { Router, UrlTree } from '@angular/router';
import { Auth } from '../services/auth';
// *****************************************************************
// Route guard that checks if the user is logged in. If not, they
// get redirected to the login page. Used on all routes that require
// an authenticated session (desktop and mobile home, etc.).
// *****************************************************************
export function authGuard(): boolean | UrlTree {
const auth = inject(Auth);
const router = inject(Router);
if (auth.isAuthenticated()) {
return true;
}
return router.createUrlTree(['/login']);
}
@@ -1,78 +1,49 @@
<!-- Phase 1: Email & Password --> <!-- Phase 1: Email & Password -->
@if (!auth.isAuthenticated() && !auth.isSelectingProgram()) { @if (!auth.isAuthenticated() && !auth.isSelectingProgram()) {
<div class="card"> <div class="card">
<h2>Login</h2> <h2>Login</h2>
@if (error()) { @if (error()) {
<p class="error">{{ error() }}</p> <p class="error">{{ error() }}</p>
} }
<form (ngSubmit)="onLogin()"> <form (ngSubmit)="onLogin()">
<label> <label>
Email Email
<input type="email" [(ngModel)]="email" name="email" required /> <input type="email" [(ngModel)]="email" name="email" required />
</label> </label>
<label> <label>
Password Password
<input type="password" [(ngModel)]="password" name="password" required /> <input type="password" [(ngModel)]="password" name="password" required />
</label> </label>
<button type="submit" [disabled]="loading()"> <button type="submit" [disabled]="loading()">
{{ loading() ? 'Signing in...' : 'Sign in' }} {{ loading() ? 'Signing in...' : 'Sign in' }}
</button> </button>
</form> </form>
</div> </div>
} }
<!-- Phase 2: Program Selection --> <!-- Phase 2: Program Selection -->
@if (auth.isSelectingProgram()) { @if (auth.isSelectingProgram()) {
<div class="card"> <div class="card">
<h2>Select a Program</h2> <h2>Select a Program</h2>
<p class="subtitle">Choose which program to log into.</p> <p class="subtitle">Choose which program to log into.</p>
@if (error()) { @if (error()) {
<p class="error">{{ error() }}</p> <p class="error">{{ error() }}</p>
}
<div class="program-list">
@for (program of auth.programs(); track program.programId) {
<button class="program-button" [disabled]="loading()" (click)="onSelectProgram(program.programId)">
<span class="program-name">{{ program.programName }}</span>
<span class="program-meta">{{ program.roleDisplayName }}{{ program.isPrimary ? ' (Primary)' : '' }}</span>
</button>
} }
<div class="program-list">
@for (program of auth.programs(); track program.programId) {
<button
class="program-button"
[disabled]="loading()"
(click)="onSelectProgram(program.programId)"
>
<span class="program-name">{{ program.programName }}</span>
<span class="program-meta">{{ program.roleDisplayName }}{{ program.isPrimary ? ' (Primary)' : '' }}</span>
</button>
}
</div>
<button class="link-button" (click)="onBackToLogin()">Back to login</button>
</div>
}
<!-- Authenticated: User Info -->
@if (auth.isAuthenticated()) {
<div class="card">
<h2>Authenticated</h2>
@if (auth.user(); as user) {
<dl>
<dt>User ID</dt>
<dd class="mono">{{ user.userId }}</dd>
<dt>Email</dt>
<dd>{{ user.email }}</dd>
<dt>Program ID</dt>
<dd class="mono">{{ user.programId }}</dd>
<dt>Role</dt>
<dd>{{ user.role }}</dd>
</dl>
}
<button (click)="onHome()">Continue</button>
<button (click)="onLogout()">Logout</button>
</div> </div>
<button class="link-button" (click)="onBackToLogin()">Back to login</button>
</div>
} }
@@ -1,7 +1,9 @@
import { Component, inject, signal } from '@angular/core'; import { Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { HttpErrorResponse } from '@angular/common/http';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { Auth } from '../../services/auth'; import { Auth } from '../../services/auth';
import { describeHttpError } from '../../classes/http-errors';
@Component({ @Component({
selector: 'app-login', selector: 'app-login',
@@ -39,9 +41,9 @@ export class Login {
this.error.set(res.message); this.error.set(res.message);
} }
}, },
error: () => { error: (err: HttpErrorResponse) => {
this.loading.set(false); this.loading.set(false);
this.error.set('Unable to reach the server.'); this.error.set(describeHttpError(err));
}, },
}); });
} }
@@ -53,24 +55,20 @@ export class Login {
this.auth.selectProgram(programId).subscribe({ this.auth.selectProgram(programId).subscribe({
next: (res) => { next: (res) => {
this.loading.set(false); this.loading.set(false);
if (!res.success) { if (res.success) {
this.router.navigateByUrl('/');
} else {
this.error.set(res.message); this.error.set(res.message);
} }
}, },
error: () => { error: (err: HttpErrorResponse) => {
this.loading.set(false); this.loading.set(false);
this.error.set('Unable to reach the server.'); this.error.set(describeHttpError(err));
}, },
}); });
} }
onHome() {
this.router.navigateByUrl('/');
}
onLogout() {
this.auth.logout().subscribe();
}
onBackToLogin() { onBackToLogin() {
this.error.set(null); this.error.set(null);
@@ -0,0 +1,42 @@
import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
// *****************************************************************
// TODO: This dummy service should be replaced by MobileHomeMeta,
// which will fetch real data from the API.
// *****************************************************************
export interface MobileHomeMeta {
programName: string; // program.name — varchar(255)
userName: string; // user.name — varchar(255)
}
@Injectable({
providedIn: 'root',
})
export class DummyMobileHomeMeta {
// ************************** Constructor **************************
// ************************** Declarations *************************
// ************************** Properties ***************************
// ************************ Public Methods *************************
// *****************************************************************
// TODO: DUMMY DATA — Returns hardcoded program and user info.
// Replace with MobileHomeMeta service that calls
// GET /api/mobile/home-meta (or similar).
// *****************************************************************
getMeta(): Observable<MobileHomeMeta> {
return of({
programName: 'WIN Program',
userName: 'Polly Balsillie',
});
}
// ************************ Event Handlers *************************
// ********************** Support Procedures ***********************
}
@@ -0,0 +1,34 @@
import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
import { ApiResult } from '../classes/api-result';
// *****************************************************************
// TODO: This dummy service should be replaced by SaveProgressEvent,
// which will POST real data to the API.
// *****************************************************************
@Injectable({
providedIn: 'root',
})
export class DummySaveProgressEvent {
// ************************** Constructor **************************
// ************************** Declarations *************************
// ************************** Properties ***************************
// ************************ Public Methods *************************
// *****************************************************************
// TODO: DUMMY — Always returns success. Replace with
// SaveProgressEvent calling POST /api/progress-events
// *****************************************************************
save(studentId: string, goalId: string, content: string): Observable<ApiResult> {
return of(ApiResult.empty());
}
// ************************ Event Handlers *************************
// ********************** Support Procedures ***********************
}
@@ -0,0 +1,94 @@
import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
// *****************************************************************
// TODO: This dummy service should be replaced by StudentGoalService,
// which will fetch real data from the API.
// *****************************************************************
export interface StudentGoalSummary {
studentIdentifier: string; // student.identifier — varchar(50)
goals: StudentGoalItem[];
}
export interface StudentGoalItem {
goalId: string; // goal.id_goal — char(36)
title: string; // goal.title — varchar(255)
description: string; // goal.description — text
category: string; // goal.category — varchar(100)
progressEventCount: number; // count of progress_event rows for this goal
}
@Injectable({
providedIn: 'root',
})
export class DummyStudentGoalService {
// ************************** Constructor **************************
// ************************** Declarations *************************
// *****************************************************************
// TODO: DUMMY DATA — Maps studentId to identifier and goals.
// Replace with StudentGoalService calling
// GET /api/students/:id/goals
// *****************************************************************
private readonly data: Record<string, StudentGoalSummary> = {
'1': {
studentIdentifier: 'J.B',
goals: [
{ goalId: 'g1', title: 'Improve reading comprehension', description: 'Work on main-idea identification and inference skills across fiction and nonfiction texts.', category: 'Academics', progressEventCount: 5 },
{ goalId: 'g2', title: 'Complete algebra module', description: 'Finish all units in the algebra course including linear equations and graphing.', category: 'Academics', progressEventCount: 2 },
{ goalId: 'g3', title: 'Weekly journal entries', description: 'Write a reflective journal entry each week to build writing fluency.', category: 'Communication', progressEventCount: 8 },
],
},
'2': {
studentIdentifier: 'M.K',
goals: [
{ goalId: 'g4', title: 'Pass certification exam', description: 'Prepare for and pass the industry certification exam by end of quarter.', category: 'Career Readiness', progressEventCount: 3 },
{ goalId: 'g5', title: 'Attendance above 90%', description: 'Maintain consistent attendance throughout the term.', category: 'Behavior', progressEventCount: 0 },
{ goalId: 'g6', title: 'Complete internship hours', description: 'Log the required 40 hours at the assigned internship site.', category: 'Career Readiness', progressEventCount: 12 },
{ goalId: 'g7', title: 'Portfolio project', description: 'Build a personal portfolio showcasing completed coursework and projects.', category: 'Career Readiness', progressEventCount: 1 },
],
},
'3': {
studentIdentifier: 'A.R',
goals: [
{ goalId: 'g8', title: 'GED preparation', description: 'Complete practice tests and study modules for GED math and reading sections.', category: 'Academics', progressEventCount: 6 },
{ goalId: 'g9', title: 'Resume workshop', description: 'Attend the resume writing workshop and produce a final draft.', category: 'Career Readiness', progressEventCount: 0 },
],
},
'4': {
studentIdentifier: 'T.W',
goals: [
{ goalId: 'g10', title: 'Public speaking practice', description: 'Present in front of the class at least once per month.', category: 'Communication', progressEventCount: 4 },
{ goalId: 'g11', title: 'Math placement improvement', description: 'Move up one placement level in math by the end of the semester.', category: 'Academics', progressEventCount: 7 },
{ goalId: 'g12', title: 'Conflict resolution strategies', description: 'Learn and apply at least three de-escalation techniques.', category: 'Behavior', progressEventCount: 2 },
{ goalId: 'g13', title: 'Daily attendance streak', description: 'Achieve a 30-day unbroken attendance streak.', category: 'Behavior', progressEventCount: 0 },
{ goalId: 'g14', title: 'Job shadow experience', description: 'Complete a job shadow day in a field of interest.', category: 'Career Readiness', progressEventCount: 1 },
],
},
'5': {
studentIdentifier: 'L.C',
goals: [
{ goalId: 'g15', title: 'Improve typing speed', description: 'Reach 40 WPM with 95% accuracy on typing assessments.', category: 'Career Readiness', progressEventCount: 3 },
],
},
};
// ************************** Properties ***************************
// ************************ Public Methods *************************
// *****************************************************************
// Returns the student's identifier and their list of goals,
// given a student ID.
// *****************************************************************
getGoalsForStudent(studentId: string): Observable<StudentGoalSummary | null> {
return of(this.data[studentId] ?? null);
}
// ************************ Event Handlers *************************
// ********************** Support Procedures ***********************
}
@@ -7,67 +7,104 @@ export type FormFactor = 'mobile' | 'desktop';
providedIn: 'root', providedIn: 'root',
}) })
export class PlatformService { export class PlatformService {
// ************************** Constructor **************************
private readonly router = inject(Router); private readonly router = inject(Router);
// ──── Raw Hardware Signals ──── // ************************** Declarations *************************
// *****************************************************************
// Checks if the device uses a touch screen (like a phone or tablet)
// rather than a mouse or trackpad.
// *****************************************************************
private readonly isCoarsePointer = private readonly isCoarsePointer =
typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches; typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches;
// *****************************************************************
// Captures the screen width and height so we can figure out what
// kind of device the user is on. Defaults to a large desktop size
// if running on the server.
// *****************************************************************
private readonly screenWidth = private readonly screenWidth =
typeof window !== 'undefined' ? window.innerWidth : 1920; typeof window !== 'undefined' ? window.innerWidth : 1920;
private readonly screenHeight = private readonly screenHeight =
typeof window !== 'undefined' ? window.innerHeight : 1080; typeof window !== 'undefined' ? window.innerHeight : 1080;
// *****************************************************************
// The shortest and longest edges of the screen, regardless of
// whether the device is held in portrait or landscape.
// *****************************************************************
private readonly minDimension = Math.min(this.screenWidth, this.screenHeight); private readonly minDimension = Math.min(this.screenWidth, this.screenHeight);
private readonly maxDimension = Math.max(this.screenWidth, this.screenHeight); private readonly maxDimension = Math.max(this.screenWidth, this.screenHeight);
// ──── Override Layer ──── // *****************************************************************
// Lets the user (or the app) manually force mobile or desktop mode
/** When non-null, overrides auto-detection. */ // instead of relying on automatic detection. When set to null, the
// app just figures it out on its own.
// *****************************************************************
private readonly formFactorOverride = signal<FormFactor | null>(null); private readonly formFactorOverride = signal<FormFactor | null>(null);
// ──── Public API ──── // ************************** Properties ***************************
/** The resolved form factor — auto-detected or overridden. */ // *****************************************************************
// The final answer: are we showing the "mobile" or "desktop"
// experience? Uses the manual override if one was set, otherwise
// auto-detects based on the device hardware.
// *****************************************************************
readonly formFactor = computed<FormFactor>(() => readonly formFactor = computed<FormFactor>(() =>
this.formFactorOverride() ?? this.resolveFormFactor(), this.formFactorOverride() ?? this.resolveFormFactor(),
); );
/** True when the user has manually toggled away from auto-detection. */ // *****************************************************************
// True when the current mode was manually chosen by the user,
// false when it was detected automatically.
// *****************************************************************
readonly isOverridden = computed(() => this.formFactorOverride() !== null); readonly isOverridden = computed(() => this.formFactorOverride() !== null);
/** // ************************ Public Methods *************************
* Switch to a specific form factor at runtime.
* Forces a route re-evaluation so the user immediately sees the other experience. // *****************************************************************
*/ // Switches between mobile and desktop mode on the fly. After
// switching, the page reloads so the correct layout appears
// immediately.
// *****************************************************************
switchTo(target: FormFactor): void { switchTo(target: FormFactor): void {
this.formFactorOverride.set(target); this.formFactorOverride.set(target);
this.router.navigateByUrl(this.router.url); this.router.navigateByUrl(this.router.url);
} }
/** Clear the override and return to auto-detected form factor. */ // *****************************************************************
// Clears any manual override and goes back to letting the device
// decide which mode to show.
// *****************************************************************
resetToAuto(): void { resetToAuto(): void {
this.formFactorOverride.set(null); this.formFactorOverride.set(null);
this.router.navigateByUrl(this.router.url); this.router.navigateByUrl(this.router.url);
} }
// ──── Device Classification Policy ──── // ************************ Event Handlers *************************
// ********************** Support Procedures ***********************
// *****************************************************************
// The rules for deciding whether a device gets the mobile or
// desktop experience:
// - Mouse/trackpad users always get desktop
// - Large tablets (like iPad Pro) get desktop
// - Medium tablets held sideways get desktop
// - Everything else (phones, small tablets) gets mobile
// *****************************************************************
private resolveFormFactor(): FormFactor { private resolveFormFactor(): FormFactor {
// Non-touch devices are always desktop
if (!this.isCoarsePointer) return 'desktop'; if (!this.isCoarsePointer) return 'desktop';
// Large tablets (iPad Pro 12.9" portrait = 1024px) → desktop
if (this.minDimension >= 1024) return 'desktop'; if (this.minDimension >= 1024) return 'desktop';
// Medium tablets in landscape with sufficient width → desktop
if (this.maxDimension >= 1180 && this.screenWidth > this.screenHeight) { if (this.maxDimension >= 1180 && this.screenWidth > this.screenHeight) {
return 'desktop'; return 'desktop';
} }
// Phones and small/portrait tablets → mobile
return 'mobile'; return 'mobile';
} }
} }
@@ -48,6 +48,22 @@ export class StudentService {
]); ]);
} }
// *****************************************************************
// TODO: DUMMY DATA — Replace with getStudentsPerUser, which will
// call GET /api/users/:id/students to return real data.
// Returns students assigned to the current user with their
// identifier, age, goal count, and progress event count.
// *****************************************************************
getDummyStudentsForUser(): Observable<StudentCardDto[]> {
return of([
{ studentId: '1', identifier: 'J.B', age: 21, lastEntryDate: '2026-02-21', goalCount: 3, progressEventCount: 5 },
{ studentId: '2', identifier: 'M.K', age: 19, lastEntryDate: '2026-02-25', goalCount: 4, progressEventCount: 8 },
{ studentId: '3', identifier: 'A.R', age: 22, lastEntryDate: null, goalCount: 2, progressEventCount: 0 },
{ studentId: '4', identifier: 'T.W', age: 20, lastEntryDate: '2026-02-18', goalCount: 5, progressEventCount: 12 },
{ studentId: '5', identifier: 'L.C', age: 18, lastEntryDate: '2026-02-27', goalCount: 1, progressEventCount: 2 },
]);
}
// ************************ Event Handlers ************************* // ************************ Event Handlers *************************
// ********************** Support Procedures *********************** // ********************** Support Procedures ***********************