Frontend
- Angular 20
+ Angular 20.1.5
Backend
@@ -1218,7 +1218,7 @@
4. Recommended Hosting
- The entire application is currently hosted online with Hetzner (www.hetzner.com), on a plan donated by team members. The team has committed to hosting for at least the next two years, and if/when that changes, will help the partner transition to the hosting platform of their choice.
+ The entire application is currently hosted online with Hetzner (www.hetzner.com), on a plan donated by team members. The team has committed to hosting for at least the next two years, and if/when that changes, will help the partner transition to the hosting platform of their choice. The team recommends staying with Hetzner based on their reliability and low cost.
@@ -1307,9 +1307,9 @@ JWT_EXPIRATION=3600
Form Factor Analysis
- Mobile Portrait: Fully responsive and functional
- Mobile Landscape: Improved readability and layout
- Desktop: Optimal user experience
+ Mobile Portrait: Fully responsive and functional - minimal UI and streamlined functionality for robust field use.
+ Mobile Landscape: Improved readability and layout - same features and user story as mobile portrait
+ Desktop: Full-featured user experience, with application configuration, administrator and edit/delete functionality
UX Observations
@@ -1329,7 +1329,7 @@ JWT_EXPIRATION=3600
13. Sustainability Considerations
- Docker deployment, free-tier hosting, and modular design support long-term maintainability.
+ Docker deployment, very inexpensive hosting, and modular design support long-term maintainability. Addcitionally, the use of free, off-the-shelf technology choices (Angular, C#, MySQL) contribute a sustainable project tech stack.
diff --git a/ui/winstudentgoaltracker/src/app/app.routes.ts b/ui/winstudentgoaltracker/src/app/app.routes.ts
index 06c3ea9..29fe943 100644
--- a/ui/winstudentgoaltracker/src/app/app.routes.ts
+++ b/ui/winstudentgoaltracker/src/app/app.routes.ts
@@ -1,11 +1,13 @@
import { inject } from '@angular/core';
import { Routes } from '@angular/router';
import { Login } from './shared/pages/login/login';
+import { Register } from './shared/pages/register/register';
import { PlatformService } from './shared/services/platform.service';
import { authGuard } from './shared/guards/auth.guard';
export const routes: Routes = [
{ path: 'login', component: Login },
+ { path: 'register', component: Register },
{
path: '',
canMatch: [() => inject(PlatformService).formFactor() === 'mobile'],
diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/goal-modal/goal-modal.html b/ui/winstudentgoaltracker/src/app/desktop/components/goal-modal/goal-modal.html
index 48dee84..63444dd 100644
--- a/ui/winstudentgoaltracker/src/app/desktop/components/goal-modal/goal-modal.html
+++ b/ui/winstudentgoaltracker/src/app/desktop/components/goal-modal/goal-modal.html
@@ -19,6 +19,29 @@
+ @if (isEditMode) {
+
+
+ Close Date
+
+
+ @if (closeDate) {
+
+
+
+ Goal achieved
+
+
+ @if (!achieved) {
+
+ Close Notes
+
+
+ }
+ }
+ }
+
@if (errorMessage()) {
{{ errorMessage() }}
}
diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/goal-modal/goal-modal.scss b/ui/winstudentgoaltracker/src/app/desktop/components/goal-modal/goal-modal.scss
index 2918c96..173467a 100644
--- a/ui/winstudentgoaltracker/src/app/desktop/components/goal-modal/goal-modal.scss
+++ b/ui/winstudentgoaltracker/src/app/desktop/components/goal-modal/goal-modal.scss
@@ -1 +1,24 @@
/* Inherits all styles from modal-shell via ::ng-deep */
+
+:host ::ng-deep {
+ .close-divider {
+ border: none;
+ border-top: 1px solid var(--border-color);
+ margin: 6px 0 14px;
+ }
+
+ .field-check {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 13px;
+ color: #333;
+ cursor: pointer;
+
+ input[type="checkbox"] {
+ width: 16px;
+ height: 16px;
+ cursor: pointer;
+ }
+ }
+}
diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/goal-modal/goal-modal.ts b/ui/winstudentgoaltracker/src/app/desktop/components/goal-modal/goal-modal.ts
index 37cd2be..f4c79a3 100644
--- a/ui/winstudentgoaltracker/src/app/desktop/components/goal-modal/goal-modal.ts
+++ b/ui/winstudentgoaltracker/src/app/desktop/components/goal-modal/goal-modal.ts
@@ -43,6 +43,11 @@ export class GoalModal {
targetCompletionDate: null,
};
+ // Close-goal fields — only used in edit mode.
+ protected closeDate: string | null = null;
+ protected achieved = false;
+ protected closeNotes = '';
+
protected get isEditMode(): boolean {
return !!this.goal();
}
@@ -66,6 +71,11 @@ export class GoalModal {
this.form.targetCompletionDate = existing.targetCompletionDate
? existing.targetCompletionDate.substring(0, 10)
: null;
+ this.closeDate = existing.closeDate
+ ? existing.closeDate.substring(0, 10)
+ : null;
+ this.achieved = existing.achieved ?? false;
+ this.closeNotes = existing.closeNotes ?? '';
} else {
// Add mode — pre-fill target date from IEP if available
const iepDate = this.nextIepDate?.();
@@ -89,6 +99,9 @@ export class GoalModal {
description: this.form.description,
baseline: this.form.baseline,
targetCompletionDate: this.form.targetCompletionDate,
+ closeDate: this.closeDate || null,
+ achieved: this.closeDate ? this.achieved : null,
+ closeNotes: this.closeDate && !this.achieved ? this.closeNotes || null : null,
},
);
this.isSubmitting.set(false);
diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/workspace/workspace.html b/ui/winstudentgoaltracker/src/app/desktop/components/workspace/workspace.html
index 4e5f6b0..3f510ac 100644
--- a/ui/winstudentgoaltracker/src/app/desktop/components/workspace/workspace.html
+++ b/ui/winstudentgoaltracker/src/app/desktop/components/workspace/workspace.html
@@ -39,6 +39,7 @@
[message]="'Delete \u0022' + (deletingBenchmark()!.shortName || deletingBenchmark()!.benchmark) + '\u0022? This cannot be undone.'"
confirmLabel="Delete"
[destructive]="true"
+ [doubleConfirm]="true"
(confirmed)="onDeleteBenchmarkConfirmed()"
(closed)="showDeleteBenchmarkConfirm.set(false)" />
}
@@ -80,6 +81,7 @@
+ Goal
@for (g of goals(); track g.goalId) {
{{ g.category }}
@@ -98,6 +100,11 @@
@if (selectedGoal()!.targetCompletionDate) {
Due {{ formatDate(selectedGoal()!.targetCompletionDate) }}
}
+ @if (selectedGoal()!.closeDate) {
+
+ {{ selectedGoal()!.achieved ? '✓ Achieved' : 'Closed' }}
+
+ }
diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/workspace/workspace.scss b/ui/winstudentgoaltracker/src/app/desktop/components/workspace/workspace.scss
index f34f998..2004b44 100644
--- a/ui/winstudentgoaltracker/src/app/desktop/components/workspace/workspace.scss
+++ b/ui/winstudentgoaltracker/src/app/desktop/components/workspace/workspace.scss
@@ -137,6 +137,28 @@
margin-left: auto;
}
+.goal-status {
+ font-size: 11px;
+ font-weight: 600;
+ padding: 3px 8px;
+ border-radius: var(--radius-sm);
+ background: #FEF3C7;
+ color: #92400E;
+}
+
+.goal-status.achieved {
+ background: #D1FAE5;
+ color: #065F46;
+}
+
+.goal-tab.closed {
+ opacity: 0.55;
+}
+
+.goal-tab.closed.active {
+ opacity: 1;
+}
+
.delete-goal-btn {
display: flex;
align-items: center;
diff --git a/ui/winstudentgoaltracker/src/app/desktop/desktop.routes.ts b/ui/winstudentgoaltracker/src/app/desktop/desktop.routes.ts
index 0e97869..7f1131f 100644
--- a/ui/winstudentgoaltracker/src/app/desktop/desktop.routes.ts
+++ b/ui/winstudentgoaltracker/src/app/desktop/desktop.routes.ts
@@ -3,6 +3,7 @@ import { Home } from './pages/home/home';
import { Workspace } from './components/workspace/workspace';
import { Reports } from './components/reports/reports';
import { StudentProgressReport } from './components/student-progress-report/student-progress-report';
+import { Admin } from './pages/admin/admin';
export default [
{
path: '',
@@ -14,6 +15,7 @@ export default [
{ path: 'students/:studentId/goals/:goalId', component: Workspace },
{ path: 'reports', component: Reports },
{ path: 'reports/student-progress', component: StudentProgressReport },
+ { path: 'admin', component: Admin },
],
},
] satisfies Routes;
diff --git a/ui/winstudentgoaltracker/src/app/desktop/pages/admin/admin.html b/ui/winstudentgoaltracker/src/app/desktop/pages/admin/admin.html
new file mode 100644
index 0000000..9ccf9b9
--- /dev/null
+++ b/ui/winstudentgoaltracker/src/app/desktop/pages/admin/admin.html
@@ -0,0 +1,138 @@
+
+
+
+ @if (error()) {
+
{{ error() }}
+ }
+
+
+ Programs
+ Users
+
+
+
+ @if (activeTab() === 'programs') {
+
+
+ + Add Program
+
+
+ @if (programs().length === 0) {
+
No programs yet. Create your first program to get started.
+ } @else {
+
+ @for (program of programs(); track program.programId) {
+
+
+
{{ program.name }}
+ @if (program.description) {
+
{{ program.description }}
+ }
+
+
Edit
+
+ }
+
+ }
+
+ }
+
+
+ @if (activeTab() === 'users') {
+
+
+ + Add User
+
+
+ @if (users().length === 0) {
+
No users yet.
+ } @else {
+
+ @for (user of users(); track user.userId + user.programId) {
+
+
+
{{ user.name }}
+
{{ user.email }} · {{ user.roleName }} · {{ user.programName }}
+
+
+ }
+
+ }
+
+ }
+
+
+
+
Database
+
+
+ @if (backingUp()) {
+ Backing up…
+ } @else {
+ Back Up Database
+ }
+
+ @if (backupSuccess()) {
+ {{ backupSuccess() }}
+ }
+
+
+
+
+
+@if (showProgramModal()) {
+
+
+
+}
+
+
+@if (showUserModal()) {
+
+
+
+ Name
+
+
+
+ Email
+
+
+
+ Password
+
+
+
+ Program
+
+ Select a program
+ @for (p of programs(); track p.programId) {
+ {{ p.name }}
+ }
+
+
+
+ Role
+
+ Select a role
+ @for (r of roles(); track r.roleId) {
+ {{ r.name }}
+ }
+
+
+ Create User
+
+
+}
diff --git a/ui/winstudentgoaltracker/src/app/desktop/pages/admin/admin.scss b/ui/winstudentgoaltracker/src/app/desktop/pages/admin/admin.scss
new file mode 100644
index 0000000..0f66847
--- /dev/null
+++ b/ui/winstudentgoaltracker/src/app/desktop/pages/admin/admin.scss
@@ -0,0 +1,193 @@
+.admin-page {
+ padding: 24px 32px;
+ max-width: 800px;
+}
+
+.admin-header h1 {
+ font-size: 22px;
+ font-weight: 600;
+ margin: 0 0 16px;
+}
+
+.error {
+ background: #fef2f2;
+ color: #dc2626;
+ padding: 10px 12px;
+ border-radius: 6px;
+ font-size: 14px;
+ margin-bottom: 12px;
+}
+
+.tabs {
+ display: flex;
+ gap: 4px;
+ margin-bottom: 20px;
+}
+
+.tab {
+ padding: 8px 18px;
+ font-size: 14px;
+ font-weight: 500;
+ border: none;
+ background: none;
+ color: var(--text-faint);
+ cursor: pointer;
+ border-radius: 8px;
+ transition: background 0.15s, color 0.15s;
+
+ &.active {
+ background: #EEF2FF;
+ color: #4338CA;
+ }
+
+ &:hover:not(.active) {
+ background: #f5f5f5;
+ }
+}
+
+.toolbar {
+ margin-bottom: 16px;
+}
+
+.action-btn {
+ padding: 8px 16px;
+ font-size: 14px;
+ font-weight: 500;
+ background: #4f46e5;
+ color: #fff;
+ border: none;
+ border-radius: 6px;
+ cursor: pointer;
+ transition: background 0.15s;
+
+ &:hover {
+ background: #4338ca;
+ }
+}
+
+.empty {
+ color: var(--text-faint);
+ font-size: 14px;
+ padding: 20px 0;
+}
+
+.list {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.list-item {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 12px 16px;
+ background: #fff;
+ border: 1px solid #e5e5e0;
+ border-radius: 10px;
+}
+
+.list-item-info {
+ flex: 1;
+ min-width: 0;
+}
+
+.list-item-name {
+ font-size: 14px;
+ font-weight: 600;
+}
+
+.list-item-meta {
+ font-size: 12px;
+ color: var(--text-faint);
+ margin-top: 2px;
+}
+
+.edit-btn {
+ padding: 4px 12px;
+ font-size: 13px;
+ background: none;
+ border: 1px solid #ddd;
+ border-radius: 6px;
+ color: #555;
+ cursor: pointer;
+ transition: background 0.15s, border-color 0.15s;
+
+ &:hover {
+ background: #f5f5f5;
+ border-color: #bbb;
+ }
+}
+
+.modal-form {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+
+ label {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ font-size: 14px;
+ font-weight: 500;
+ color: #333;
+ }
+
+ input, textarea, select {
+ padding: 8px 10px;
+ border: 1px solid #ddd;
+ border-radius: 6px;
+ font-size: 14px;
+ font-family: inherit;
+ outline: none;
+ transition: border-color 0.15s;
+
+ &:focus {
+ border-color: #4f46e5;
+ }
+ }
+
+ button[type='submit'] {
+ padding: 8px 16px;
+ font-size: 14px;
+ font-weight: 500;
+ background: #4f46e5;
+ color: #fff;
+ border: none;
+ border-radius: 6px;
+ cursor: pointer;
+ margin-top: 4px;
+ transition: background 0.15s;
+
+ &:hover {
+ background: #4338ca;
+ }
+ }
+}
+
+.section-divider {
+ border-top: 1px solid #e5e5e0;
+ margin: 28px 0 20px;
+}
+
+.section-heading {
+ font-size: 16px;
+ font-weight: 600;
+ margin: 0 0 12px;
+}
+
+.backup-row {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.backup-btn:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+.backup-success {
+ font-size: 13px;
+ color: #16a34a;
+}
diff --git a/ui/winstudentgoaltracker/src/app/desktop/pages/admin/admin.ts b/ui/winstudentgoaltracker/src/app/desktop/pages/admin/admin.ts
new file mode 100644
index 0000000..03546ed
--- /dev/null
+++ b/ui/winstudentgoaltracker/src/app/desktop/pages/admin/admin.ts
@@ -0,0 +1,141 @@
+import { Component, inject, signal } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { AdminService, ProgramDto, DistrictUserDto, RoleDto } from '../../../shared/services/admin.service';
+import { ModalShell } from '../../components/modal-shell/modal-shell';
+
+@Component({
+ selector: 'app-admin',
+ imports: [FormsModule, ModalShell],
+ templateUrl: './admin.html',
+ styleUrl: './admin.scss',
+})
+export class Admin {
+
+ // ************************** Constructor **************************
+
+ constructor() {
+ this.loadAll();
+ }
+
+ // ************************** Declarations *************************
+
+ private readonly adminService = inject(AdminService);
+
+ protected readonly activeTab = signal<'programs' | 'users'>('programs');
+ protected readonly programs = signal([]);
+ protected readonly users = signal([]);
+ protected readonly roles = signal([]);
+ protected readonly error = signal(null);
+
+ // Program modal
+ protected readonly showProgramModal = signal(false);
+ protected readonly editingProgram = signal(null);
+ protected programName = '';
+ protected programDescription = '';
+
+ // User modal
+ protected readonly showUserModal = signal(false);
+ protected userName = '';
+ protected userEmail = '';
+ protected userPassword = '';
+ protected userProgramId = '';
+ protected userRoleId = '';
+
+ // Backup
+ protected readonly backingUp = signal(false);
+ protected readonly backupSuccess = signal(null);
+
+ // ************************ Event Handlers *************************
+
+ onSwitchTab(tab: 'programs' | 'users') {
+ this.activeTab.set(tab);
+ }
+
+ // --- Programs ---
+
+ onAddProgram() {
+ this.editingProgram.set(null);
+ this.programName = '';
+ this.programDescription = '';
+ this.showProgramModal.set(true);
+ }
+
+ onEditProgram(program: ProgramDto) {
+ this.editingProgram.set(program);
+ this.programName = program.name;
+ this.programDescription = program.description || '';
+ this.showProgramModal.set(true);
+ }
+
+ async onSaveProgram() {
+ this.error.set(null);
+ const editing = this.editingProgram();
+
+ if (editing) {
+ const result = await this.adminService.updateProgram(editing.programId, this.programName, this.programDescription);
+ if (!result.success) {
+ this.error.set(result.message);
+ return;
+ }
+ } else {
+ const result = await this.adminService.createProgram(this.programName, this.programDescription);
+ if (!result.success) {
+ this.error.set(result.message);
+ return;
+ }
+ }
+
+ this.showProgramModal.set(false);
+ this.programs.set(await this.adminService.getPrograms());
+ }
+
+ // --- Users ---
+
+ onAddUser() {
+ this.userName = '';
+ this.userEmail = '';
+ this.userPassword = '';
+ this.userProgramId = '';
+ this.userRoleId = '';
+ this.showUserModal.set(true);
+ }
+
+ async onSaveUser() {
+ this.error.set(null);
+ const result = await this.adminService.createUser(
+ this.userEmail, this.userName, this.userPassword,
+ this.userProgramId, this.userRoleId
+ );
+ if (!result.success) {
+ this.error.set(result.message);
+ return;
+ }
+ this.showUserModal.set(false);
+ this.users.set(await this.adminService.getUsers());
+ }
+
+ // --- Backup ---
+
+ async onBackupDatabase() {
+ this.backingUp.set(true);
+ this.backupSuccess.set(null);
+ this.error.set(null);
+ try {
+ await this.adminService.backupDatabase();
+ this.backupSuccess.set('Backup downloaded successfully.');
+ } catch {
+ this.error.set('Database backup failed. Please try again.');
+ } finally {
+ this.backingUp.set(false);
+ }
+ }
+
+ // ********************** Support Procedures ***********************
+
+ private async loadAll() {
+ this.programs.set(await this.adminService.getPrograms());
+ this.users.set(await this.adminService.getUsers());
+ this.roles.set(await this.adminService.getRoles());
+ }
+}
+
diff --git a/ui/winstudentgoaltracker/src/app/desktop/pages/home/home.html b/ui/winstudentgoaltracker/src/app/desktop/pages/home/home.html
index 3264b52..1ef22e2 100644
--- a/ui/winstudentgoaltracker/src/app/desktop/pages/home/home.html
+++ b/ui/winstudentgoaltracker/src/app/desktop/pages/home/home.html
@@ -54,6 +54,9 @@
}
diff --git a/ui/winstudentgoaltracker/src/app/shared/pages/login/login.ts b/ui/winstudentgoaltracker/src/app/shared/pages/login/login.ts
index 99a3227..da574fc 100644
--- a/ui/winstudentgoaltracker/src/app/shared/pages/login/login.ts
+++ b/ui/winstudentgoaltracker/src/app/shared/pages/login/login.ts
@@ -1,13 +1,13 @@
import { Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpErrorResponse } from '@angular/common/http';
-import { Router } from '@angular/router';
+import { Router, RouterLink } from '@angular/router';
import { Auth } from '../../services/auth';
import { describeHttpError } from '../../classes/http-errors';
@Component({
selector: 'app-login',
- imports: [FormsModule],
+ imports: [FormsModule, RouterLink],
templateUrl: './login.html',
styleUrl: './login.css',
})
diff --git a/ui/winstudentgoaltracker/src/app/shared/pages/register/register.html b/ui/winstudentgoaltracker/src/app/shared/pages/register/register.html
new file mode 100644
index 0000000..e4a0e41
--- /dev/null
+++ b/ui/winstudentgoaltracker/src/app/shared/pages/register/register.html
@@ -0,0 +1,71 @@
+@if (success()) {
+
+
Registration Complete
+
Your district has been created. You can now log in.
+
Go to Login
+
+} @else {
+
+}
diff --git a/ui/winstudentgoaltracker/src/app/shared/pages/register/register.ts b/ui/winstudentgoaltracker/src/app/shared/pages/register/register.ts
new file mode 100644
index 0000000..fba5658
--- /dev/null
+++ b/ui/winstudentgoaltracker/src/app/shared/pages/register/register.ts
@@ -0,0 +1,64 @@
+import { Component, inject, signal } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { Router, RouterLink } from '@angular/router';
+import { Api } from '../../services/api';
+
+@Component({
+ selector: 'app-register',
+ imports: [FormsModule, RouterLink],
+ templateUrl: './register.html',
+ styleUrl: './register.css',
+})
+export class Register {
+
+ // ************************** Constructor **************************
+
+ private readonly api = inject(Api);
+ private readonly router = inject(Router);
+
+ // ************************** Declarations *************************
+
+ name = '';
+ email = '';
+ password = '';
+ districtName = '';
+ districtContactEmail = '';
+ programName = '';
+ programDescription = '';
+
+ // ************************** Properties ***************************
+
+ protected readonly loading = signal(false);
+ protected readonly error = signal(null);
+ protected readonly success = signal(false);
+
+ // ************************ Event Handlers *************************
+
+ onRegister() {
+ this.error.set(null);
+ this.loading.set(true);
+
+ this.api.register({
+ email: this.email,
+ password: this.password,
+ name: this.name,
+ districtName: this.districtName,
+ districtContactEmail: this.districtContactEmail || undefined,
+ programName: this.programName,
+ programDescription: this.programDescription || undefined,
+ }).subscribe({
+ next: (res) => {
+ this.loading.set(false);
+ if (res.success) {
+ this.success.set(true);
+ } else {
+ this.error.set(res.message);
+ }
+ },
+ error: () => {
+ this.loading.set(false);
+ this.error.set('An unexpected error occurred. Please try again.');
+ },
+ });
+ }
+}
diff --git a/ui/winstudentgoaltracker/src/app/shared/services/api.ts b/ui/winstudentgoaltracker/src/app/shared/services/api.ts
index 9f43e38..17353f9 100644
--- a/ui/winstudentgoaltracker/src/app/shared/services/api.ts
+++ b/ui/winstudentgoaltracker/src/app/shared/services/api.ts
@@ -53,6 +53,12 @@ export class Api {
);
}
-
+ // Self-service registration — creates a new district + program + user
+ register(request: { email: string; password: string; name: string; districtName: string; districtContactEmail?: string; programName: string; programDescription?: string }): Observable> {
+ return this.http.post>(
+ `${this.base}/api/Auth/Register`,
+ request,
+ );
+ }
}