mirror of
https://github.com/opelly27/WinStudentGoalTracker.git
synced 2026-05-20 02:57:36 +00:00
Latest
This commit is contained in:
@@ -7,3 +7,4 @@
|
||||
/.vs/WinStudentGoalTracker/FileContentIndex
|
||||
/.vs/WinStudentGoalTracker/DesignTimeBuild
|
||||
/.vs/WinStudentGoalTracker/v17
|
||||
/.vs
|
||||
|
||||
@@ -2,16 +2,19 @@ import { inject } from '@angular/core';
|
||||
import { Routes } from '@angular/router';
|
||||
import { Login } from './shared/pages/login/login';
|
||||
import { PlatformService } from './shared/services/platform.service';
|
||||
import { authGuard } from './shared/guards/auth.guard';
|
||||
|
||||
export const routes: Routes = [
|
||||
{ path: 'login', component: Login },
|
||||
{
|
||||
path: '',
|
||||
canMatch: [() => inject(PlatformService).formFactor() === 'mobile'],
|
||||
canActivate: [authGuard],
|
||||
loadChildren: () => import('./mobile/mobile.routes'),
|
||||
},
|
||||
{
|
||||
path: '',
|
||||
canActivate: [authGuard],
|
||||
loadChildren: () => import('./desktop/desktop.routes'),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
<header class="header">
|
||||
<button class="menu-toggle" (click)="onToggleSidebar()">☰</button>
|
||||
<span class="header-title">WinStudentGoalTracker</span>
|
||||
<span class="spacer"></span>
|
||||
<button class="logout-btn" (click)="onLogout()">Log Out</button>
|
||||
</header>
|
||||
|
||||
<!-- Body: Sidebar + Main -->
|
||||
|
||||
@@ -42,6 +42,24 @@
|
||||
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 {
|
||||
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 { Auth } from '../../../shared/services/auth';
|
||||
|
||||
@Component({
|
||||
selector: 'app-home',
|
||||
@@ -13,6 +14,7 @@ export class Home {
|
||||
|
||||
// ************************** Declarations *************************
|
||||
|
||||
private readonly auth = inject(Auth);
|
||||
protected readonly sidebarExpanded = signal(false);
|
||||
|
||||
// ************************** Properties ***************************
|
||||
@@ -25,5 +27,13 @@ export class Home {
|
||||
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 ***********************
|
||||
}
|
||||
|
||||
@@ -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 { 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 [
|
||||
{ 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;
|
||||
|
||||
+27
@@ -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>
|
||||
+132
@@ -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;
|
||||
}
|
||||
+23
@@ -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();
|
||||
});
|
||||
});
|
||||
+85
@@ -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({
|
||||
selector: 'app-home',
|
||||
imports: [],
|
||||
imports: [RouterOutlet],
|
||||
templateUrl: './home.html',
|
||||
styleUrl: './home.scss',
|
||||
})
|
||||
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 -->
|
||||
@if (!auth.isAuthenticated() && !auth.isSelectingProgram()) {
|
||||
<div class="card">
|
||||
<h2>Login</h2>
|
||||
<div class="card">
|
||||
<h2>Login</h2>
|
||||
|
||||
@if (error()) {
|
||||
<p class="error">{{ error() }}</p>
|
||||
}
|
||||
@if (error()) {
|
||||
<p class="error">{{ error() }}</p>
|
||||
}
|
||||
|
||||
<form (ngSubmit)="onLogin()">
|
||||
<label>
|
||||
Email
|
||||
<input type="email" [(ngModel)]="email" name="email" required />
|
||||
</label>
|
||||
<form (ngSubmit)="onLogin()">
|
||||
<label>
|
||||
Email
|
||||
<input type="email" [(ngModel)]="email" name="email" required />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Password
|
||||
<input type="password" [(ngModel)]="password" name="password" required />
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<input type="password" [(ngModel)]="password" name="password" required />
|
||||
</label>
|
||||
|
||||
<button type="submit" [disabled]="loading()">
|
||||
{{ loading() ? 'Signing in...' : 'Sign in' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<button type="submit" [disabled]="loading()">
|
||||
{{ loading() ? 'Signing in...' : 'Sign in' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Phase 2: Program Selection -->
|
||||
@if (auth.isSelectingProgram()) {
|
||||
<div class="card">
|
||||
<h2>Select a Program</h2>
|
||||
<p class="subtitle">Choose which program to log into.</p>
|
||||
<div class="card">
|
||||
<h2>Select a Program</h2>
|
||||
<p class="subtitle">Choose which program to log into.</p>
|
||||
|
||||
@if (error()) {
|
||||
<p class="error">{{ error() }}</p>
|
||||
@if (error()) {
|
||||
<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>
|
||||
}
|
||||
<button class="link-button" (click)="onBackToLogin()">Back to login</button>
|
||||
</div>
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { Router } from '@angular/router';
|
||||
import { Auth } from '../../services/auth';
|
||||
import { describeHttpError } from '../../classes/http-errors';
|
||||
|
||||
@Component({
|
||||
selector: 'app-login',
|
||||
@@ -39,9 +41,9 @@ export class Login {
|
||||
this.error.set(res.message);
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
error: (err: HttpErrorResponse) => {
|
||||
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({
|
||||
next: (res) => {
|
||||
this.loading.set(false);
|
||||
if (!res.success) {
|
||||
if (res.success) {
|
||||
this.router.navigateByUrl('/');
|
||||
} else {
|
||||
this.error.set(res.message);
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
error: (err: HttpErrorResponse) => {
|
||||
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() {
|
||||
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',
|
||||
})
|
||||
export class PlatformService {
|
||||
|
||||
// ************************** Constructor **************************
|
||||
|
||||
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 =
|
||||
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 =
|
||||
typeof window !== 'undefined' ? window.innerWidth : 1920;
|
||||
|
||||
private readonly screenHeight =
|
||||
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 maxDimension = Math.max(this.screenWidth, this.screenHeight);
|
||||
|
||||
// ──── Override Layer ────
|
||||
|
||||
/** When non-null, overrides auto-detection. */
|
||||
// *****************************************************************
|
||||
// Lets the user (or the app) manually force mobile or desktop mode
|
||||
// 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);
|
||||
|
||||
// ──── 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>(() =>
|
||||
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);
|
||||
|
||||
/**
|
||||
* Switch to a specific form factor at runtime.
|
||||
* Forces a route re-evaluation so the user immediately sees the other experience.
|
||||
*/
|
||||
// ************************ Public Methods *************************
|
||||
|
||||
// *****************************************************************
|
||||
// Switches between mobile and desktop mode on the fly. After
|
||||
// switching, the page reloads so the correct layout appears
|
||||
// immediately.
|
||||
// *****************************************************************
|
||||
switchTo(target: FormFactor): void {
|
||||
this.formFactorOverride.set(target);
|
||||
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 {
|
||||
this.formFactorOverride.set(null);
|
||||
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 {
|
||||
// Non-touch devices are always desktop
|
||||
if (!this.isCoarsePointer) return 'desktop';
|
||||
|
||||
// Large tablets (iPad Pro 12.9" portrait = 1024px) → desktop
|
||||
if (this.minDimension >= 1024) return 'desktop';
|
||||
|
||||
// Medium tablets in landscape with sufficient width → desktop
|
||||
if (this.maxDimension >= 1180 && this.screenWidth > this.screenHeight) {
|
||||
return 'desktop';
|
||||
}
|
||||
|
||||
// Phones and small/portrait tablets → 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 *************************
|
||||
|
||||
// ********************** Support Procedures ***********************
|
||||
|
||||
Reference in New Issue
Block a user