diff --git a/.gitignore b/.gitignore
index 91c9ff8..02287c1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,7 @@
/prototype/RolesAssignments/.vs
/prototype/RolesAssignments/bin
/prototype/RolesAssignments/obj
+/.vs/WinStudentGoalTracker/CopilotIndices
+/.vs/WinStudentGoalTracker/FileContentIndex
+/.vs/WinStudentGoalTracker/DesignTimeBuild
+/.vs/WinStudentGoalTracker/v17
diff --git a/db/Objects/procedures/sp_Student_Delete.sql b/db/Objects/procedures/sp_Student_Delete.sql
index e4d6f24..811a21d 100644
--- a/db/Objects/procedures/sp_Student_Delete.sql
+++ b/db/Objects/procedures/sp_Student_Delete.sql
@@ -1,6 +1,8 @@
DELIMITER ;;
CREATE DEFINER=`root`@`%` PROCEDURE `sp_Student_Delete`(IN p_id_student CHAR(36))
BEGIN
+ DELETE FROM user_student
+ WHERE id_student = p_id_student;
DELETE FROM student
WHERE id_student = p_id_student;
SELECT ROW_COUNT() AS rows_affected;
diff --git a/db/Objects/procedures/sp_Student_GetAll.sql b/db/Objects/procedures/sp_Student_GetAll.sql
index d421313..f0b31cf 100644
--- a/db/Objects/procedures/sp_Student_GetAll.sql
+++ b/db/Objects/procedures/sp_Student_GetAll.sql
@@ -4,7 +4,6 @@ BEGIN
SELECT
id_student,
id_program,
- id_user,
identifier,
program_year,
enrollment_date,
diff --git a/db/Objects/procedures/sp_Student_GetById.sql b/db/Objects/procedures/sp_Student_GetById.sql
index 1e9b742..3017116 100644
--- a/db/Objects/procedures/sp_Student_GetById.sql
+++ b/db/Objects/procedures/sp_Student_GetById.sql
@@ -4,7 +4,6 @@ BEGIN
SELECT
id_student,
id_program,
- id_user,
identifier,
program_year,
enrollment_date,
diff --git a/db/Objects/procedures/sp_Student_GetByProgram.sql b/db/Objects/procedures/sp_Student_GetByProgram.sql
deleted file mode 100644
index 5e70f9e..0000000
--- a/db/Objects/procedures/sp_Student_GetByProgram.sql
+++ /dev/null
@@ -1,19 +0,0 @@
-DELIMITER ;;
-CREATE DEFINER=`root`@`%` PROCEDURE `sp_Student_GetByProgram`(
- IN p_id_program CHAR(36)
-)
-BEGIN
- SELECT
- id_student,
- id_program,
- id_user,
- identifier,
- program_year,
- enrollment_date,
- expected_grad,
- created_at
- FROM student
- WHERE id_program = p_id_program
- ORDER BY id_student;
-END;;
-DELIMITER ;
diff --git a/db/Objects/procedures/sp_Student_GetByUserAndProgram.sql b/db/Objects/procedures/sp_Student_GetByUserAndProgram.sql
deleted file mode 100644
index 13e28ac..0000000
--- a/db/Objects/procedures/sp_Student_GetByUserAndProgram.sql
+++ /dev/null
@@ -1,21 +0,0 @@
-DELIMITER ;;
-CREATE DEFINER=`root`@`%` PROCEDURE `sp_Student_GetByUserAndProgram`(
- IN p_id_user CHAR(36),
- IN p_id_program CHAR(36)
-)
-BEGIN
- SELECT
- s.id_student,
- s.id_program,
- s.id_user,
- s.identifier,
- s.program_year,
- s.enrollment_date,
- s.expected_grad,
- s.created_at
- FROM student s
- WHERE s.id_user = p_id_user
- AND s.id_program = p_id_program
- ORDER BY s.id_student;
-END;;
-DELIMITER ;
diff --git a/db/Objects/procedures/sp_Student_GetWithAssignments.sql b/db/Objects/procedures/sp_Student_GetWithAssignments.sql
new file mode 100644
index 0000000..cd2a4e3
--- /dev/null
+++ b/db/Objects/procedures/sp_Student_GetWithAssignments.sql
@@ -0,0 +1,30 @@
+DELIMITER ;;
+CREATE DEFINER=`root`@`%` PROCEDURE `sp_Student_GetWithAssignments`(
+ IN p_id_program CHAR(36),
+ IN p_id_user CHAR(36)
+)
+BEGIN
+ -- Result set 1: All students in the program
+ SELECT
+ s.id_student,
+ s.id_program,
+ s.identifier,
+ s.program_year,
+ s.enrollment_date,
+ s.expected_grad,
+ s.created_at
+ FROM student s
+ WHERE s.id_program = p_id_program
+ ORDER BY s.id_student;
+ -- Result set 2: user_student assignments for the requesting user in this program
+ SELECT
+ us.id_user_student,
+ us.id_user,
+ us.id_student,
+ us.is_primary
+ FROM user_student us
+ INNER JOIN student s ON s.id_student = us.id_student
+ WHERE us.id_user = p_id_user
+ AND s.id_program = p_id_program;
+END;;
+DELIMITER ;
diff --git a/db/Objects/procedures/sp_Student_Insert.sql b/db/Objects/procedures/sp_Student_Insert.sql
index e4bd77c..2e06e6b 100644
--- a/db/Objects/procedures/sp_Student_Insert.sql
+++ b/db/Objects/procedures/sp_Student_Insert.sql
@@ -13,7 +13,6 @@ BEGIN
(
id_student,
id_program,
- id_user,
identifier,
program_year,
enrollment_date,
@@ -24,17 +23,17 @@ BEGIN
(
p_id_student,
p_id_program,
- p_id_user,
p_identifier,
p_program_year,
p_enrollment_date,
p_expected_grad,
UTC_TIMESTAMP()
);
+ INSERT INTO user_student (id_user_student, id_user, id_student, is_primary)
+ VALUES (UUID(), p_id_user, p_id_student, 1);
SELECT
id_student,
id_program,
- id_user,
identifier,
program_year,
enrollment_date,
diff --git a/db/Objects/tables/student.sql b/db/Objects/tables/student.sql
index d5f51e6..44c4d76 100644
--- a/db/Objects/tables/student.sql
+++ b/db/Objects/tables/student.sql
@@ -1,7 +1,6 @@
CREATE TABLE `student` (
`id_student` char(36) NOT NULL,
`id_program` char(36) DEFAULT NULL,
- `id_user` char(36) DEFAULT NULL,
`identifier` varchar(50) DEFAULT NULL,
`program_year` int DEFAULT NULL,
`enrollment_date` date DEFAULT NULL,
@@ -9,7 +8,5 @@ CREATE TABLE `student` (
`created_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`id_student`),
KEY `student_ibfk_1` (`id_program`),
- KEY `student_ibfk_2` (`id_user`),
- CONSTRAINT `student_ibfk_1` FOREIGN KEY (`id_program`) REFERENCES `program` (`id_program`),
- CONSTRAINT `student_ibfk_2` FOREIGN KEY (`id_user`) REFERENCES `user` (`id_user`)
+ CONSTRAINT `student_ibfk_1` FOREIGN KEY (`id_program`) REFERENCES `program` (`id_program`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
diff --git a/ui/winstudentgoaltracker/src/app/app.config.ts b/ui/winstudentgoaltracker/src/app/app.config.ts
index b7cb093..9673019 100644
--- a/ui/winstudentgoaltracker/src/app/app.config.ts
+++ b/ui/winstudentgoaltracker/src/app/app.config.ts
@@ -3,7 +3,7 @@ import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
-import { authInterceptor } from './interceptors/auth.interceptor';
+import { authInterceptor } from './shared/interceptors/auth.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
diff --git a/ui/winstudentgoaltracker/src/app/app.routes.ts b/ui/winstudentgoaltracker/src/app/app.routes.ts
index 6725dc3..f955d85 100644
--- a/ui/winstudentgoaltracker/src/app/app.routes.ts
+++ b/ui/winstudentgoaltracker/src/app/app.routes.ts
@@ -1,7 +1,17 @@
+import { inject } from '@angular/core';
import { Routes } from '@angular/router';
-import { Login } from './pages/login/login';
+import { Login } from './shared/pages/login/login';
+import { PlatformService } from './shared/services/platform.service';
export const routes: Routes = [
- { path: '', component: Login },
{ path: 'login', component: Login },
+ {
+ path: '',
+ canMatch: [() => inject(PlatformService).formFactor() === 'mobile'],
+ loadChildren: () => import('./mobile/mobile.routes'),
+ },
+ {
+ path: '',
+ loadChildren: () => import('./desktop/desktop.routes'),
+ },
];
diff --git a/ui/winstudentgoaltracker/src/app/desktop/desktop.routes.ts b/ui/winstudentgoaltracker/src/app/desktop/desktop.routes.ts
new file mode 100644
index 0000000..9e556cb
--- /dev/null
+++ b/ui/winstudentgoaltracker/src/app/desktop/desktop.routes.ts
@@ -0,0 +1,14 @@
+import { Routes } from '@angular/router';
+import { Home } from './pages/home/home';
+import { StudentCardList } from './pages/components/student-card-list/student-card-list';
+
+export default [
+ {
+ path: '',
+ component: Home,
+ children: [
+ { path: '', redirectTo: 'students', pathMatch: 'full' },
+ { path: 'students', component: StudentCardList },
+ ],
+ },
+] satisfies Routes;
diff --git a/ui/winstudentgoaltracker/src/app/desktop/pages/components/student-card-list/student-card-list.html b/ui/winstudentgoaltracker/src/app/desktop/pages/components/student-card-list/student-card-list.html
new file mode 100644
index 0000000..79a45e3
--- /dev/null
+++ b/ui/winstudentgoaltracker/src/app/desktop/pages/components/student-card-list/student-card-list.html
@@ -0,0 +1,14 @@
+
+
+
+
+@if (displayMode() === 'card') {
+
+ @for (student of students(); track student.studentId) {
+
+ }
+
+} @else {
+
+List view coming soon.
+}
\ No newline at end of file
diff --git a/ui/winstudentgoaltracker/src/app/desktop/pages/components/student-card-list/student-card-list.scss b/ui/winstudentgoaltracker/src/app/desktop/pages/components/student-card-list/student-card-list.scss
new file mode 100644
index 0000000..559d66b
--- /dev/null
+++ b/ui/winstudentgoaltracker/src/app/desktop/pages/components/student-card-list/student-card-list.scss
@@ -0,0 +1,41 @@
+:host {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+}
+
+.toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ height: 40px;
+ padding-right: 0.5rem;
+ border-radius: 8px;
+ background: #fff;
+ border-bottom: 1px solid #ddd;
+ margin-bottom: 1rem;
+ flex-shrink: 0;
+}
+
+.toolbar-btn {
+ padding: 0.375rem 0.75rem;
+ background: transparent;
+ color: #4f46e5;
+ border: 1px solid #4f46e5;
+ border-radius: 6px;
+ font-size: 0.875rem;
+ font-weight: 500;
+ cursor: pointer;
+}
+
+.toolbar-btn:hover {
+ background: #eef2ff;
+}
+
+.card-grid {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 1rem;
+ overflow-y: auto;
+ flex: 1;
+}
\ No newline at end of file
diff --git a/ui/winstudentgoaltracker/src/app/desktop/pages/components/student-card-list/student-card-list.ts b/ui/winstudentgoaltracker/src/app/desktop/pages/components/student-card-list/student-card-list.ts
new file mode 100644
index 0000000..15c7b4c
--- /dev/null
+++ b/ui/winstudentgoaltracker/src/app/desktop/pages/components/student-card-list/student-card-list.ts
@@ -0,0 +1,48 @@
+import { Component, inject, signal } from '@angular/core';
+import { StudentCard } from '../student-card/student-card';
+import { StudentService } from '../../../../shared/services/student.service';
+import { StudentCardDto } from '../../../../shared/models/dto/student-card.dto';
+
+export type DisplayMode = 'card' | 'list';
+
+@Component({
+ selector: 'app-student-card-list',
+ imports: [StudentCard],
+ templateUrl: './student-card-list.html',
+ styleUrl: './student-card-list.scss',
+})
+export class StudentCardList {
+
+ // ************************** Constructor **************************
+
+ constructor() {
+ this.loadStudents();
+ }
+
+ // ************************** Declarations *************************
+
+ private readonly studentService = inject(StudentService);
+ protected readonly students = signal([]);
+ protected readonly displayMode = signal('card');
+
+ // ************************** Properties ***************************
+
+ // ************************ Public Methods *************************
+
+ // ************************ Event Handlers *************************
+
+ onAddStudent() {
+ // TODO: navigate to add-student form
+ }
+
+ // ********************** Support Procedures ***********************
+
+ // *****************************************************************
+ // Loads students from the service and populates the students signal.
+ // *****************************************************************
+ private loadStudents() {
+ this.studentService.getStudentCards().subscribe(data => {
+ this.students.set(data);
+ });
+ }
+}
diff --git a/ui/winstudentgoaltracker/src/app/desktop/pages/components/student-card/student-card.html b/ui/winstudentgoaltracker/src/app/desktop/pages/components/student-card/student-card.html
new file mode 100644
index 0000000..d427c77
--- /dev/null
+++ b/ui/winstudentgoaltracker/src/app/desktop/pages/components/student-card/student-card.html
@@ -0,0 +1,19 @@
+
+
🎓 {{ student().identifier }}
+
+
+ Age: {{ student().age }}
+
+ @if (student().lastEntryDate) {
+ Last entry: {{ student().lastEntryDate | date:'M/d/yy' }}
+ } @else {
+ No entries yet
+ }
+
+
+
+
+ Goals: {{ student().goalCount }}
+ Events: {{ student().progressEventCount }}
+
+
\ No newline at end of file
diff --git a/ui/winstudentgoaltracker/src/app/desktop/pages/components/student-card/student-card.scss b/ui/winstudentgoaltracker/src/app/desktop/pages/components/student-card/student-card.scss
new file mode 100644
index 0000000..d5af2c1
--- /dev/null
+++ b/ui/winstudentgoaltracker/src/app/desktop/pages/components/student-card/student-card.scss
@@ -0,0 +1,42 @@
+:host {
+ display: block;
+ width: 280px;
+}
+
+.card {
+ background: #fff;
+ border-radius: 8px;
+ box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
+ padding: 1.5rem;
+}
+
+.identifier {
+ margin: 0 0 0.75rem;
+ font-size: 1.5rem;
+ text-align: center;
+}
+
+.meta {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 1rem;
+ font-size: 0.875rem;
+ color: #666;
+}
+
+.badge {
+ padding: 0.25rem 0.5rem;
+ border: 1px solid #ddd;
+ border-radius: 6px;
+ font-size: 0.8125rem;
+ color: #333;
+}
+
+.stats {
+ display: flex;
+ justify-content: space-around;
+ font-size: 0.875rem;
+ font-weight: 500;
+ color: #333;
+}
\ No newline at end of file
diff --git a/ui/winstudentgoaltracker/src/app/desktop/pages/components/student-card/student-card.ts b/ui/winstudentgoaltracker/src/app/desktop/pages/components/student-card/student-card.ts
new file mode 100644
index 0000000..061f528
--- /dev/null
+++ b/ui/winstudentgoaltracker/src/app/desktop/pages/components/student-card/student-card.ts
@@ -0,0 +1,26 @@
+import { Component, input } from '@angular/core';
+import { DatePipe } from '@angular/common';
+import { StudentCardDto } from '../../../../shared/models/dto/student-card.dto';
+
+@Component({
+ selector: 'app-student-card',
+ imports: [DatePipe],
+ templateUrl: './student-card.html',
+ styleUrl: './student-card.scss',
+})
+export class StudentCard {
+
+ // ************************** Constructor **************************
+
+ // ************************** Declarations *************************
+
+ readonly student = input.required();
+
+ // ************************** Properties ***************************
+
+ // ************************ Public Methods *************************
+
+ // ************************ Event Handlers *************************
+
+ // ********************** Support Procedures ***********************
+}
diff --git a/ui/winstudentgoaltracker/src/app/desktop/pages/home/home.html b/ui/winstudentgoaltracker/src/app/desktop/pages/home/home.html
new file mode 100644
index 0000000..3ef3bc5
--- /dev/null
+++ b/ui/winstudentgoaltracker/src/app/desktop/pages/home/home.html
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/ui/winstudentgoaltracker/src/app/desktop/pages/home/home.scss b/ui/winstudentgoaltracker/src/app/desktop/pages/home/home.scss
new file mode 100644
index 0000000..7a7f232
--- /dev/null
+++ b/ui/winstudentgoaltracker/src/app/desktop/pages/home/home.scss
@@ -0,0 +1,110 @@
+:host {
+ display: block;
+ height: 100%;
+}
+
+.shell {
+ display: flex;
+ flex-direction: column;
+ height: 100vh;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+}
+
+/* Header */
+.header {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 0 1rem;
+ height: 48px;
+ background: #fff;
+ border-bottom: 1px solid #ddd;
+ flex-shrink: 0;
+}
+
+.menu-toggle {
+ background: none;
+ border: 1px solid #ddd;
+ border-radius: 6px;
+ font-size: 1rem;
+ cursor: pointer;
+ padding: 0.25rem 0.5rem;
+ color: #333;
+}
+
+.menu-toggle:hover {
+ background: #f5f5f5;
+}
+
+.header-title {
+ font-weight: 600;
+ font-size: 1rem;
+ color: #333;
+}
+
+/* Body: Sidebar + Content */
+.body {
+ display: flex;
+ flex: 1;
+ overflow: hidden;
+}
+
+.sidebar {
+ width: 0;
+ background: #fff;
+ border-right: 1px solid #ddd;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ transition: width 0.2s ease;
+ flex-shrink: 0;
+}
+
+.sidebar.expanded {
+ width: 220px;
+}
+
+.nav-item {
+ display: block;
+ padding: 0.75rem 1rem;
+ color: #333;
+ text-decoration: none;
+ white-space: nowrap;
+ font-size: 0.875rem;
+}
+
+.nav-item:hover {
+ background: #f5f5f5;
+}
+
+.nav-item.sub {
+ padding-left: 2rem;
+ font-size: 0.8125rem;
+}
+
+.nav-item.active {
+ font-weight: 600;
+ color: #4f46e5;
+ background: #eef2ff;
+}
+
+/* Main content */
+.content {
+ flex: 1;
+ overflow-y: auto;
+ padding: 1.5rem;
+ background: #f5f5f5;
+}
+
+/* Footer */
+.footer {
+ display: flex;
+ align-items: center;
+ padding: 0 1rem;
+ height: 32px;
+ background: #fff;
+ border-top: 1px solid #ddd;
+ color: #666;
+ font-size: 0.75rem;
+ flex-shrink: 0;
+}
\ No newline at end of file
diff --git a/ui/winstudentgoaltracker/src/app/desktop/pages/home/home.ts b/ui/winstudentgoaltracker/src/app/desktop/pages/home/home.ts
new file mode 100644
index 0000000..3c3278c
--- /dev/null
+++ b/ui/winstudentgoaltracker/src/app/desktop/pages/home/home.ts
@@ -0,0 +1,29 @@
+import { Component, signal } from '@angular/core';
+import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
+
+@Component({
+ selector: 'app-home',
+ imports: [RouterOutlet, RouterLink, RouterLinkActive],
+ templateUrl: './home.html',
+ styleUrl: './home.scss',
+})
+export class Home {
+
+ // ************************** Constructor **************************
+
+ // ************************** Declarations *************************
+
+ protected readonly sidebarExpanded = signal(false);
+
+ // ************************** Properties ***************************
+
+ // ************************ Public Methods *************************
+
+ // ************************ Event Handlers *************************
+
+ onToggleSidebar() {
+ this.sidebarExpanded.update(v => !v);
+ }
+
+ // ********************** Support Procedures ***********************
+}
diff --git a/ui/winstudentgoaltracker/src/app/mobile/mobile.routes.ts b/ui/winstudentgoaltracker/src/app/mobile/mobile.routes.ts
new file mode 100644
index 0000000..168c5c2
--- /dev/null
+++ b/ui/winstudentgoaltracker/src/app/mobile/mobile.routes.ts
@@ -0,0 +1,6 @@
+import { Routes } from '@angular/router';
+import { Home } from './pages/home/home';
+
+export default [
+ { path: '', component: Home },
+] satisfies Routes;
diff --git a/ui/winstudentgoaltracker/src/app/mobile/pages/home/home.html b/ui/winstudentgoaltracker/src/app/mobile/pages/home/home.html
new file mode 100644
index 0000000..a72ce19
--- /dev/null
+++ b/ui/winstudentgoaltracker/src/app/mobile/pages/home/home.html
@@ -0,0 +1 @@
+Mobile home works!
diff --git a/ui/winstudentgoaltracker/src/app/mobile/pages/home/home.scss b/ui/winstudentgoaltracker/src/app/mobile/pages/home/home.scss
new file mode 100644
index 0000000..e69de29
diff --git a/ui/winstudentgoaltracker/src/app/mobile/pages/home/home.ts b/ui/winstudentgoaltracker/src/app/mobile/pages/home/home.ts
new file mode 100644
index 0000000..fe83818
--- /dev/null
+++ b/ui/winstudentgoaltracker/src/app/mobile/pages/home/home.ts
@@ -0,0 +1,11 @@
+import { Component } from '@angular/core';
+
+@Component({
+ selector: 'app-home',
+ imports: [],
+ templateUrl: './home.html',
+ styleUrl: './home.scss',
+})
+export class Home {
+
+}
diff --git a/ui/winstudentgoaltracker/src/app/pages/login/login.ts b/ui/winstudentgoaltracker/src/app/pages/login/login.ts
deleted file mode 100644
index 02d31fe..0000000
--- a/ui/winstudentgoaltracker/src/app/pages/login/login.ts
+++ /dev/null
@@ -1,310 +0,0 @@
-import { Component, inject, signal } from '@angular/core';
-import { FormsModule } from '@angular/forms';
-import { Auth } from '../../services/auth';
-
-@Component({
- selector: 'app-login',
- imports: [FormsModule],
- template: `
-
- @if (!auth.isAuthenticated() && !auth.isSelectingProgram()) {
-
-
Login
-
- @if (error()) {
-
{{ error() }}
- }
-
-
-
- }
-
-
- @if (auth.isSelectingProgram()) {
-
-
Select a Program
-
Choose which program to log into.
-
- @if (error()) {
-
{{ error() }}
- }
-
-
- @for (program of auth.programs(); track program.programId) {
-
- }
-
-
-
-
- }
-
-
- @if (auth.isAuthenticated()) {
-
-
Authenticated
-
- @if (auth.user(); as user) {
-
- - User ID
- - {{ user.userId }}
-
- - Email
- - {{ user.email }}
-
- - Program ID
- - {{ user.programId }}
-
- - Role
- - {{ user.role }}
-
- }
-
-
-
- }
- `,
- styles: `
- :host {
- display: flex;
- justify-content: center;
- align-items: center;
- min-height: 100vh;
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
- background: #f5f5f5;
- padding: 1rem;
- }
-
- .card {
- background: #fff;
- border-radius: 8px;
- box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
- padding: 2rem;
- width: 100%;
- max-width: 420px;
- }
-
- h2 {
- margin: 0 0 0.5rem;
- font-size: 1.5rem;
- }
-
- .subtitle {
- margin: 0 0 1rem;
- color: #666;
- font-size: 0.875rem;
- }
-
- form {
- display: flex;
- flex-direction: column;
- gap: 1rem;
- margin-top: 1rem;
- }
-
- label {
- display: flex;
- flex-direction: column;
- gap: 0.25rem;
- font-size: 0.875rem;
- font-weight: 500;
- color: #333;
- }
-
- input {
- padding: 0.625rem 0.75rem;
- border: 1px solid #ddd;
- border-radius: 6px;
- font-size: 0.9375rem;
- outline: none;
- transition: border-color 0.15s;
- }
-
- input:focus {
- border-color: #4f46e5;
- }
-
- button[type='submit'],
- button:not(.program-button):not(.link-button) {
- padding: 0.625rem 1rem;
- background: #4f46e5;
- color: #fff;
- border: none;
- border-radius: 6px;
- font-size: 0.9375rem;
- font-weight: 500;
- cursor: pointer;
- transition: background 0.15s;
- margin-top: 0.5rem;
- }
-
- button[type='submit']:hover,
- button:not(.program-button):not(.link-button):hover {
- background: #4338ca;
- }
-
- button:disabled {
- opacity: 0.6;
- cursor: not-allowed;
- }
-
- .error {
- background: #fef2f2;
- color: #dc2626;
- padding: 0.625rem 0.75rem;
- border-radius: 6px;
- font-size: 0.875rem;
- margin: 0.5rem 0;
- }
-
- .program-list {
- display: flex;
- flex-direction: column;
- gap: 0.5rem;
- }
-
- .program-button {
- display: flex;
- flex-direction: column;
- align-items: flex-start;
- gap: 0.125rem;
- padding: 0.75rem 1rem;
- background: #f9fafb;
- border: 1px solid #e5e7eb;
- border-radius: 6px;
- cursor: pointer;
- transition: background 0.15s, border-color 0.15s;
- text-align: left;
- width: 100%;
- }
-
- .program-button:hover {
- background: #eef2ff;
- border-color: #4f46e5;
- }
-
- .program-name {
- font-weight: 500;
- font-size: 0.9375rem;
- color: #111;
- }
-
- .program-meta {
- font-size: 0.8125rem;
- color: #666;
- }
-
- .link-button {
- background: none;
- border: none;
- color: #4f46e5;
- cursor: pointer;
- font-size: 0.875rem;
- padding: 0.5rem 0;
- margin-top: 0.75rem;
- }
-
- .link-button:hover {
- text-decoration: underline;
- }
-
- dl {
- display: grid;
- grid-template-columns: auto 1fr;
- gap: 0.5rem 1rem;
- margin: 1rem 0;
- font-size: 0.9375rem;
- }
-
- dt {
- font-weight: 500;
- color: #555;
- }
-
- dd {
- margin: 0;
- color: #111;
- word-break: break-all;
- }
-
- .mono {
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
- font-size: 0.8125rem;
- }
- `,
-})
-export class Login {
- protected readonly auth = inject(Auth);
-
- protected email = '';
- protected password = '';
- protected readonly loading = signal(false);
- protected readonly error = signal(null);
-
- onLogin() {
- this.error.set(null);
- this.loading.set(true);
-
- this.auth.login(this.email, this.password).subscribe({
- next: (res) => {
- this.loading.set(false);
- if (!res.success) {
- this.error.set(res.message);
- }
- },
- error: () => {
- this.loading.set(false);
- this.error.set('Unable to reach the server.');
- },
- });
- }
-
- onSelectProgram(programId: string) {
- this.error.set(null);
- this.loading.set(true);
-
- this.auth.selectProgram(programId).subscribe({
- next: (res) => {
- this.loading.set(false);
- if (!res.success) {
- this.error.set(res.message);
- }
- },
- error: () => {
- this.loading.set(false);
- this.error.set('Unable to reach the server.');
- },
- });
- }
-
- onLogout() {
- this.auth.logout().subscribe();
- }
-
- onBackToLogin() {
- this.error.set(null);
- // Clear session state so we go back to the login form
- this.auth.logout().subscribe();
- }
-}
diff --git a/ui/winstudentgoaltracker/src/app/interceptors/auth.interceptor.ts b/ui/winstudentgoaltracker/src/app/shared/interceptors/auth.interceptor.ts
similarity index 100%
rename from ui/winstudentgoaltracker/src/app/interceptors/auth.interceptor.ts
rename to ui/winstudentgoaltracker/src/app/shared/interceptors/auth.interceptor.ts
diff --git a/ui/winstudentgoaltracker/src/app/models/auth.models.ts b/ui/winstudentgoaltracker/src/app/shared/models/auth.models.ts
similarity index 100%
rename from ui/winstudentgoaltracker/src/app/models/auth.models.ts
rename to ui/winstudentgoaltracker/src/app/shared/models/auth.models.ts
diff --git a/ui/winstudentgoaltracker/src/app/shared/models/dto/student-card.dto.ts b/ui/winstudentgoaltracker/src/app/shared/models/dto/student-card.dto.ts
new file mode 100644
index 0000000..5cec264
--- /dev/null
+++ b/ui/winstudentgoaltracker/src/app/shared/models/dto/student-card.dto.ts
@@ -0,0 +1,8 @@
+export interface StudentCardDto {
+ studentId: string;
+ identifier: string;
+ age: number;
+ lastEntryDate: string | null;
+ goalCount: number;
+ progressEventCount: number;
+}
diff --git a/ui/winstudentgoaltracker/src/app/shared/pages/login/login.css b/ui/winstudentgoaltracker/src/app/shared/pages/login/login.css
new file mode 100644
index 0000000..c3b3bfa
--- /dev/null
+++ b/ui/winstudentgoaltracker/src/app/shared/pages/login/login.css
@@ -0,0 +1,166 @@
+:host {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ min-height: 100vh;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+ background: #f5f5f5;
+ padding: 1rem;
+}
+
+.card {
+ background: #fff;
+ border-radius: 8px;
+ box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
+ padding: 2rem;
+ width: 100%;
+ max-width: 420px;
+}
+
+h2 {
+ margin: 0 0 0.5rem;
+ font-size: 1.5rem;
+}
+
+.subtitle {
+ margin: 0 0 1rem;
+ color: #666;
+ font-size: 0.875rem;
+}
+
+form {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ margin-top: 1rem;
+}
+
+label {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ font-size: 0.875rem;
+ font-weight: 500;
+ color: #333;
+}
+
+input {
+ padding: 0.625rem 0.75rem;
+ border: 1px solid #ddd;
+ border-radius: 6px;
+ font-size: 0.9375rem;
+ outline: none;
+ transition: border-color 0.15s;
+}
+
+input:focus {
+ border-color: #4f46e5;
+}
+
+button[type='submit'],
+button:not(.program-button):not(.link-button) {
+ padding: 0.625rem 1rem;
+ background: #4f46e5;
+ color: #fff;
+ border: none;
+ border-radius: 6px;
+ font-size: 0.9375rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: background 0.15s;
+ margin-top: 0.5rem;
+}
+
+button[type='submit']:hover,
+button:not(.program-button):not(.link-button):hover {
+ background: #4338ca;
+}
+
+button:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+.error {
+ background: #fef2f2;
+ color: #dc2626;
+ padding: 0.625rem 0.75rem;
+ border-radius: 6px;
+ font-size: 0.875rem;
+ margin: 0.5rem 0;
+}
+
+.program-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.program-button {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 0.125rem;
+ padding: 0.75rem 1rem;
+ background: #f9fafb;
+ border: 1px solid #e5e7eb;
+ border-radius: 6px;
+ cursor: pointer;
+ transition: background 0.15s, border-color 0.15s;
+ text-align: left;
+ width: 100%;
+}
+
+.program-button:hover {
+ background: #eef2ff;
+ border-color: #4f46e5;
+}
+
+.program-name {
+ font-weight: 500;
+ font-size: 0.9375rem;
+ color: #111;
+}
+
+.program-meta {
+ font-size: 0.8125rem;
+ color: #666;
+}
+
+.link-button {
+ background: none;
+ border: none;
+ color: #4f46e5;
+ cursor: pointer;
+ font-size: 0.875rem;
+ padding: 0.5rem 0;
+ margin-top: 0.75rem;
+}
+
+.link-button:hover {
+ text-decoration: underline;
+}
+
+dl {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ gap: 0.5rem 1rem;
+ margin: 1rem 0;
+ font-size: 0.9375rem;
+}
+
+dt {
+ font-weight: 500;
+ color: #555;
+}
+
+dd {
+ margin: 0;
+ color: #111;
+ word-break: break-all;
+}
+
+.mono {
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 0.8125rem;
+}
diff --git a/ui/winstudentgoaltracker/src/app/shared/pages/login/login.html b/ui/winstudentgoaltracker/src/app/shared/pages/login/login.html
new file mode 100644
index 0000000..a902090
--- /dev/null
+++ b/ui/winstudentgoaltracker/src/app/shared/pages/login/login.html
@@ -0,0 +1,78 @@
+
+@if (!auth.isAuthenticated() && !auth.isSelectingProgram()) {
+
+
Login
+
+ @if (error()) {
+
{{ error() }}
+ }
+
+
+
+}
+
+
+@if (auth.isSelectingProgram()) {
+
+
Select a Program
+
Choose which program to log into.
+
+ @if (error()) {
+
{{ error() }}
+ }
+
+
+ @for (program of auth.programs(); track program.programId) {
+
+ }
+
+
+
+
+}
+
+
+@if (auth.isAuthenticated()) {
+
+
Authenticated
+
+ @if (auth.user(); as user) {
+
+ - User ID
+ - {{ user.userId }}
+
+ - Email
+ - {{ user.email }}
+
+ - Program ID
+ - {{ user.programId }}
+
+ - Role
+ - {{ user.role }}
+
+ }
+
+
+
+}
diff --git a/ui/winstudentgoaltracker/src/app/shared/pages/login/login.ts b/ui/winstudentgoaltracker/src/app/shared/pages/login/login.ts
new file mode 100644
index 0000000..abf66a4
--- /dev/null
+++ b/ui/winstudentgoaltracker/src/app/shared/pages/login/login.ts
@@ -0,0 +1,82 @@
+import { Component, inject, signal } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { Router } from '@angular/router';
+import { Auth } from '../../services/auth';
+
+@Component({
+ selector: 'app-login',
+ imports: [FormsModule],
+ templateUrl: './login.html',
+ styleUrl: './login.css',
+})
+export class Login {
+
+ // ************************** Constructor **************************
+
+ // ************************** Declarations *************************
+
+ private readonly router = inject(Router);
+ protected readonly auth = inject(Auth);
+ protected email = '';
+ protected password = '';
+ protected readonly loading = signal(false);
+ protected readonly error = signal(null);
+
+ // ************************** Properties ***************************
+
+ // ************************ Public Methods *************************
+
+ // ************************ Event Handlers *************************
+
+ onLogin() {
+ this.error.set(null);
+ this.loading.set(true);
+
+ this.auth.login(this.email, this.password).subscribe({
+ next: (res) => {
+ this.loading.set(false);
+ if (!res.success) {
+ this.error.set(res.message);
+ }
+ },
+ error: () => {
+ this.loading.set(false);
+ this.error.set('Unable to reach the server.');
+ },
+ });
+ }
+
+ onSelectProgram(programId: string) {
+ this.error.set(null);
+ this.loading.set(true);
+
+ this.auth.selectProgram(programId).subscribe({
+ next: (res) => {
+ this.loading.set(false);
+ if (!res.success) {
+ this.error.set(res.message);
+ }
+ },
+ error: () => {
+ this.loading.set(false);
+ this.error.set('Unable to reach the server.');
+ },
+ });
+ }
+
+ onHome() {
+ this.router.navigateByUrl('/');
+ }
+
+ onLogout() {
+ this.auth.logout().subscribe();
+ }
+
+ onBackToLogin() {
+ this.error.set(null);
+ // Clear session state so we go back to the login form
+ this.auth.logout().subscribe();
+ }
+
+ // ********************** Support Procedures ***********************
+}
diff --git a/ui/winstudentgoaltracker/src/app/services/api.spec.ts b/ui/winstudentgoaltracker/src/app/shared/services/api.spec.ts
similarity index 100%
rename from ui/winstudentgoaltracker/src/app/services/api.spec.ts
rename to ui/winstudentgoaltracker/src/app/shared/services/api.spec.ts
diff --git a/ui/winstudentgoaltracker/src/app/services/api.ts b/ui/winstudentgoaltracker/src/app/shared/services/api.ts
similarity index 96%
rename from ui/winstudentgoaltracker/src/app/services/api.ts
rename to ui/winstudentgoaltracker/src/app/shared/services/api.ts
index 85a2189..6efa537 100644
--- a/ui/winstudentgoaltracker/src/app/services/api.ts
+++ b/ui/winstudentgoaltracker/src/app/shared/services/api.ts
@@ -1,7 +1,7 @@
import { HttpClient } from '@angular/common/http';
import { inject, Injectable } from '@angular/core';
import { Observable } from 'rxjs';
-import { environment } from '../../environments/environment';
+import { environment } from '../../../environments/environment';
import {
LoginRequest,
LoginResponse,
diff --git a/ui/winstudentgoaltracker/src/app/services/auth.spec.ts b/ui/winstudentgoaltracker/src/app/shared/services/auth.spec.ts
similarity index 100%
rename from ui/winstudentgoaltracker/src/app/services/auth.spec.ts
rename to ui/winstudentgoaltracker/src/app/shared/services/auth.spec.ts
diff --git a/ui/winstudentgoaltracker/src/app/services/auth.ts b/ui/winstudentgoaltracker/src/app/shared/services/auth.ts
similarity index 100%
rename from ui/winstudentgoaltracker/src/app/services/auth.ts
rename to ui/winstudentgoaltracker/src/app/shared/services/auth.ts
diff --git a/ui/winstudentgoaltracker/src/app/shared/services/platform.service.ts b/ui/winstudentgoaltracker/src/app/shared/services/platform.service.ts
new file mode 100644
index 0000000..2492644
--- /dev/null
+++ b/ui/winstudentgoaltracker/src/app/shared/services/platform.service.ts
@@ -0,0 +1,73 @@
+import { computed, inject, Injectable, signal } from '@angular/core';
+import { Router } from '@angular/router';
+
+export type FormFactor = 'mobile' | 'desktop';
+
+@Injectable({
+ providedIn: 'root',
+})
+export class PlatformService {
+ private readonly router = inject(Router);
+
+ // ──── Raw Hardware Signals ────
+
+ private readonly isCoarsePointer =
+ typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches;
+
+ private readonly screenWidth =
+ typeof window !== 'undefined' ? window.innerWidth : 1920;
+
+ private readonly screenHeight =
+ typeof window !== 'undefined' ? window.innerHeight : 1080;
+
+ 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. */
+ private readonly formFactorOverride = signal(null);
+
+ // ──── Public API ────
+
+ /** The resolved form factor — auto-detected or overridden. */
+ readonly formFactor = computed(() =>
+ this.formFactorOverride() ?? this.resolveFormFactor(),
+ );
+
+ /** True when the user has manually toggled away from auto-detection. */
+ 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.
+ */
+ switchTo(target: FormFactor): void {
+ this.formFactorOverride.set(target);
+ this.router.navigateByUrl(this.router.url);
+ }
+
+ /** Clear the override and return to auto-detected form factor. */
+ resetToAuto(): void {
+ this.formFactorOverride.set(null);
+ this.router.navigateByUrl(this.router.url);
+ }
+
+ // ──── Device Classification Policy ────
+
+ 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';
+ }
+}
diff --git a/ui/winstudentgoaltracker/src/app/shared/services/student.service.ts b/ui/winstudentgoaltracker/src/app/shared/services/student.service.ts
new file mode 100644
index 0000000..304a1b7
--- /dev/null
+++ b/ui/winstudentgoaltracker/src/app/shared/services/student.service.ts
@@ -0,0 +1,54 @@
+import { Injectable } from '@angular/core';
+import { Observable, of } from 'rxjs';
+import { StudentCardDto } from '../models/dto/student-card.dto';
+
+@Injectable({
+ providedIn: 'root',
+})
+export class StudentService {
+
+ // ************************** Constructor **************************
+
+ // ************************** Declarations *************************
+
+ // ************************** Properties ***************************
+
+ // ************************ Public Methods *************************
+
+ // *****************************************************************
+ // Returns student card summaries. Currently returns dummy data
+ // until the API endpoint is available.
+ // *****************************************************************
+ getStudentCards(): Observable {
+ 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,
+ },
+ ]);
+ }
+
+ // ************************ Event Handlers *************************
+
+ // ********************** Support Procedures ***********************
+}
diff --git a/ui/winstudentgoaltracker/src/styles.scss b/ui/winstudentgoaltracker/src/styles.scss
index 90d4ee0..65fcaae 100644
--- a/ui/winstudentgoaltracker/src/styles.scss
+++ b/ui/winstudentgoaltracker/src/styles.scss
@@ -1 +1,9 @@
/* You can add global styles to this file, and also import other style files */
+
+html,
+body {
+ margin: 0;
+ padding: 0;
+ height: 100%;
+ overflow: hidden;
+}
\ No newline at end of file