-
-
+
+ @if (showAddStudentModal()) {
+
+ }
+ @if (editingStudent()) {
+
+ }
-
-
-
+
+
-
-
+
+
+
+
\ 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
index 96bb09a..594f681 100644
--- a/ui/winstudentgoaltracker/src/app/desktop/pages/home/home.scss
+++ b/ui/winstudentgoaltracker/src/app/desktop/pages/home/home.scss
@@ -5,120 +5,225 @@
.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 {
- flex: 1;
- text-align: center;
- font-weight: 600;
- font-size: 1rem;
- color: #333;
-}
-
-.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;
- flex: 1;
- overflow: hidden;
+ font-family: var(--font-family);
+ background: var(--bg-page);
+ color: var(--text-primary);
}
+/* ─── Sidebar ─── */
.sidebar {
- width: 0;
- background: #fff;
- border-right: 1px solid #ddd;
+ width: 260px;
+ border-right: 1px solid var(--border-color);
+ background: var(--bg-surface);
display: flex;
flex-direction: column;
- overflow: hidden;
- transition: width 0.2s ease;
flex-shrink: 0;
}
-.sidebar.expanded {
- width: 260px;
- overflow-y: auto;
+.sidebar-brand {
+ padding: 20px 18px 12px;
+ border-bottom: 1px solid var(--border-color);
}
-.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.active {
+.brand-label {
+ font-size: 13px;
font-weight: 600;
- color: #4f46e5;
- background: #eef2ff;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: var(--text-muted);
+ margin-bottom: 4px;
}
-/* Main content */
-.content {
- flex: 1;
- overflow-y: auto;
- padding: 1.5rem;
- background: #f5f5f5;
- display: flex;
- flex-direction: column;
+.brand-sub {
+ font-size: 15px;
+ color: var(--text-secondary);
}
-/* Footer */
-.footer {
+.sidebar-controls {
+ padding: 12px 18px 8px;
display: flex;
align-items: center;
- padding: 0 1rem;
- height: 32px;
+ gap: 8px;
+}
+
+.controls-label {
+ font-size: 12px;
+ color: var(--text-muted);
+}
+
+.scope-toggle {
+ margin-left: auto;
+ font-size: 11px;
+ color: var(--text-faint);
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ cursor: pointer;
+ user-select: none;
+}
+
+.toggle-track {
+ display: inline-flex;
+ align-items: center;
+ width: 30px;
+ height: 16px;
+ background: #d1d5db;
+ border-radius: 8px;
+ padding: 2px;
+ transition: background var(--transition-normal);
+
+ &.active {
+ background: var(--accent-indigo-light);
+ }
+}
+
+.toggle-thumb {
+ width: 12px;
+ height: 12px;
background: #fff;
- border-top: 1px solid #ddd;
- color: #666;
- font-size: 0.75rem;
+ border-radius: 50%;
+ transition: transform var(--transition-normal);
+
+ .active > & {
+ transform: translateX(14px);
+ }
+}
+
+/* ─── Student List ─── */
+.student-list {
+ flex: 1;
+ overflow-y: auto;
+ padding: 0 8px 8px;
+}
+
+.student-item {
+ padding: 10px 12px;
+ border-radius: var(--radius-lg);
+ cursor: pointer;
+ margin-bottom: 2px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ transition: background var(--transition-fast);
+
+ &:hover {
+ background: var(--bg-hover);
+ }
+
+ &.active {
+ background: var(--bg-hover);
+ }
+}
+
+.student-item-info {
+ flex: 1;
+ min-width: 0;
+}
+
+.student-item-name {
+ font-size: 14px;
+ font-weight: 400;
+
+ &.bold {
+ font-weight: 600;
+ }
+}
+
+.student-item-meta {
+ font-size: 11px;
+ color: var(--text-faint);
+ margin-top: 2px;
+}
+
+.owner-tag {
+ margin-left: 6px;
+ color: var(--text-dim);
+}
+
+.edit-pencil {
+ background: none;
+ border: none;
+ cursor: pointer;
+ padding: 2px;
flex-shrink: 0;
+ display: flex;
+ opacity: 0;
+ transition: opacity var(--transition-fast);
+
+ .student-item:hover & {
+ opacity: 1;
+ }
+}
+
+/* ─── Sidebar Footer ─── */
+.sidebar-footer {
+ padding: 10px 12px;
+ border-top: 1px solid var(--border-color);
+}
+
+.add-student-btn {
+ width: 100%;
+ padding: 8px 0;
+ border-radius: var(--radius-md);
+ border: 1.5px dashed var(--border-muted);
+ background: none;
+ font-size: 13px;
+ color: var(--text-muted);
+ cursor: pointer;
+ font-family: inherit;
+ transition: border-color var(--transition-fast), color var(--transition-fast);
+
+ &:hover {
+ border-color: var(--text-muted);
+ color: var(--text-secondary);
+ }
+}
+
+.sidebar-nav {
+ padding: 6px 12px;
+ border-top: 1px solid var(--border-color);
+}
+
+.nav-link {
+ display: block;
+ padding: 8px 10px;
+ border-radius: var(--radius-md);
+ font-size: 13px;
+ color: var(--text-secondary);
+ text-decoration: none;
+ transition: background var(--transition-fast);
+
+ &:hover {
+ background: var(--bg-hover);
+ }
+}
+
+.sidebar-bottom {
+ padding: 8px 12px;
+ border-top: 1px solid var(--border-color);
+}
+
+.logout-link {
+ background: none;
+ border: none;
+ font-size: 12px;
+ color: var(--text-faint);
+ cursor: pointer;
+ padding: 6px 10px;
+ font-family: inherit;
+
+ &:hover {
+ color: var(--text-secondary);
+ }
+}
+
+/* ─── Main Content ─── */
+.main {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ overflow: hidden;
+ background: var(--bg-page);
}
\ 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
index 33331a5..390459d 100644
--- a/ui/winstudentgoaltracker/src/app/desktop/pages/home/home.ts
+++ b/ui/winstudentgoaltracker/src/app/desktop/pages/home/home.ts
@@ -1,274 +1,103 @@
-import { Component, effect, inject, OnDestroy, signal } from '@angular/core';
-import { NavigationEnd, Router, RouterLink, RouterOutlet } from '@angular/router';
-import { Subscription } from 'rxjs';
-import { filter } from 'rxjs/operators';
+import { Component, effect, inject, signal } from '@angular/core';
+import { RouterLink, RouterOutlet, Router } from '@angular/router';
import { Auth } from '../../../shared/services/auth';
import { StudentService } from '../../../shared/services/student.service';
import { StudentCardDto } from '../../../shared/classes/student-card.dto';
-import { SidebarNode } from '../../../shared/classes/sidebar-node';
-import { SidebarTreeNode } from '../../components/sidebar-tree-node/sidebar-tree-node';
+import { AddStudentModal } from '../../components/add-student-modal/add-student-modal';
+import { EditStudentModal } from '../../components/edit-student-modal/edit-student-modal';
@Component({
- selector: 'app-home',
- imports: [RouterOutlet, RouterLink, SidebarTreeNode],
- templateUrl: './home.html',
- styleUrl: './home.scss',
+ selector: 'app-home',
+ imports: [RouterOutlet, RouterLink, AddStudentModal, EditStudentModal],
+ templateUrl: './home.html',
+ styleUrl: './home.scss',
})
-export class Home implements OnDestroy {
+export class Home {
- // ************************** Constructor **************************
+ // ************************** Constructor **************************
- constructor() {
- this.loadStudents();
-
- // Reload the sidebar tree whenever data changes elsewhere.
- let initialized = false;
- effect(() => {
- this.studentService.dataVersion();
- if (initialized) {
+ constructor() {
this.loadStudents();
- }
- initialized = true;
- });
- // Auto-expand sidebar nodes to match the current route.
- this.routeSub = this.router.events.pipe(
- filter(e => e instanceof NavigationEnd)
- ).subscribe(() => {
- this.expandToRoute(this.router.url);
- });
-
- // Patch individual sidebar node labels without a full rebuild.
- this.labelSub = this.studentService.sidebarLabelUpdate$.subscribe(update => {
- this.patchNodeLabel(this.sidebarTree(), update.routerLink, update.label);
- });
- }
-
- // ************************** Declarations *************************
-
- protected readonly auth = inject(Auth);
- private readonly router = inject(Router);
- private readonly studentService = inject(StudentService);
- private readonly routeSub: Subscription;
- private readonly labelSub: Subscription;
- protected readonly sidebarExpanded = signal(true);
- protected readonly sidebarTree = signal
([]);
-
- // ************************** Properties ***************************
-
- // ************************ Public Methods *************************
-
- // ************************ Event Handlers *************************
-
- onToggleSidebar() {
- 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();
- }
-
- ngOnDestroy() {
- this.routeSub.unsubscribe();
- this.labelSub.unsubscribe();
- }
-
- // ********************** Support Procedures ***********************
-
- // *****************************************************************
- // Recursively walks the sidebar tree to find a node whose
- // routerLink matches the given link, and updates its label.
- // *****************************************************************
- private patchNodeLabel(nodes: SidebarNode[], routerLink: string[], label: string): boolean {
- for (const node of nodes) {
- if (node.routerLink && node.routerLink.join('/') === routerLink.join('/')) {
- node.label = label;
- return true;
- }
- if (node.children && this.patchNodeLabel(node.children, routerLink, label)) {
- return true;
- }
- }
- return false;
- }
-
- // *****************************************************************
- // Loads student list, sorts by identifier, and builds the sidebar
- // tree with lazy-loading callbacks for goals and benchmarks.
- // When scope is 'all', groups students by owning user.
- // *****************************************************************
- private loadStudents() {
- // Fetch with 'all' scope so the sidebar can show grouped nodes
- // when the StudentCardList toggle is active.
- this.studentService.getMyStudents('all').then(data => {
- if (data.success) {
- const sorted = (data.payload || []).sort((a, b) =>
- a.identifier.localeCompare(b.identifier, undefined, { sensitivity: 'base' })
- );
- this.sidebarTree.set(this.buildTree(sorted));
- this.expandToRoute(this.router.url);
- }
- });
- }
-
- // *****************************************************************
- // Builds the sidebar node tree from a list of students.
- // Groups students by ownerName — "My Students" first, then other
- // owners' groups sorted alphabetically.
- // *****************************************************************
- private buildTree(students: StudentCardDto[]): SidebarNode[] {
- // Group students by ownerName. Students with isMine go into "My Students".
- const myStudents: StudentCardDto[] = [];
- const otherGroups = new Map();
-
- for (const s of students) {
- if (s.isMine !== false) {
- myStudents.push(s);
- } else {
- const key = s.ownerName ?? 'Unknown';
- if (!otherGroups.has(key)) otherGroups.set(key, []);
- otherGroups.get(key)!.push(s);
- }
+ // Reload student list when data changes elsewhere.
+ let initialized = false;
+ effect(() => {
+ this.studentService.dataVersion();
+ if (initialized) {
+ this.loadStudents();
+ }
+ initialized = true;
+ });
}
- const nodes: SidebarNode[] = [{
- label: 'My Students',
- routerLink: ['/students'],
- expanded: true,
- childCount: myStudents.length,
- children: myStudents.map(s => this.buildStudentNode(s)),
- }];
+ // ************************** Declarations *************************
- // Add other users' groups sorted by owner name.
- const sortedOwners = [...otherGroups.keys()].sort((a, b) =>
- a.localeCompare(b, undefined, { sensitivity: 'base' })
- );
+ protected readonly auth = inject(Auth);
+ private readonly router = inject(Router);
+ private readonly studentService = inject(StudentService);
- for (const ownerName of sortedOwners) {
- const group = otherGroups.get(ownerName)!;
- const firstName = ownerName.split(' ')[0];
- nodes.push({
- label: `${firstName}'s Students`,
- routerLink: ['/students'],
- expanded: false,
- childCount: group.length,
- children: group.map(s => this.buildStudentNode(s)),
- });
+ protected readonly students = signal([]);
+ protected readonly selectedStudentId = signal(null);
+ protected readonly showAll = signal(false);
+ protected readonly showAddStudentModal = signal(false);
+ protected readonly editingStudent = signal(null);
+
+ // ************************ Event Handlers *************************
+
+ onSelectStudent(student: StudentCardDto) {
+ this.selectedStudentId.set(student.studentId);
+ this.router.navigate(['/students', student.studentId]);
}
- nodes.push({
- label: 'Reports',
- routerLink: ['/reports'],
- });
-
- return nodes;
- }
-
- // *****************************************************************
- // Builds a single student sidebar node with lazy-loaded goal
- // children.
- // *****************************************************************
- private buildStudentNode(s: StudentCardDto): SidebarNode {
- return {
- label: s.identifier,
- routerLink: ['/students', s.studentId],
- childCount: s.goalCount > 0 ? 1 : 0,
- children: s.goalCount > 0 ? [{
- label: 'Goals',
- routerLink: ['/students', s.studentId, 'goals'],
- childCount: s.goalCount,
- loadChildren: () => this.loadGoalNodes(s.studentId),
- }] : undefined,
- };
- }
-
-
-
- // *****************************************************************
- // Lazy-loads individual goal nodes for a student. Called when
- // the "Goals" node is expanded for the first time.
- // *****************************************************************
- private async loadGoalNodes(studentId: string): Promise {
- const result = await this.studentService.getGoalsForStudent(studentId);
- if (!result.success || !result.payload) return [];
-
- return result.payload.goals.map(goal => ({
- label: goal.category,
- routerLink: ['/students', studentId, 'goals', goal.goalId],
- childCount: 2,
- children: [
- {
- label: goal.progressEventCount > 0 ? `Progress Events (${goal.progressEventCount})` : 'Progress Events',
- routerLink: ['/students', studentId, 'goals', goal.goalId, 'progress'],
- },
- {
- label: 'Benchmarks',
- routerLink: ['/students', studentId, 'goals', goal.goalId, 'benchmarks'],
- childCount: goal.benchmarkCount,
- loadChildren: goal.benchmarkCount > 0
- ? () => this.loadBenchmarkNodes(studentId, goal.goalId)
- : undefined,
- },
- ],
- }));
- }
-
- // *****************************************************************
- // Lazy-loads benchmark leaf nodes for a goal. Called when a
- // "Benchmarks" node is expanded for the first time.
- // *****************************************************************
- private async loadBenchmarkNodes(studentId: string, goalId: string): Promise {
- const result = await this.studentService.getBenchmarksForStudent(studentId);
- if (!result.success || !result.payload) return [];
-
- return result.payload.benchmarks
- .filter(b => b.goalId === goalId)
- .map(b => ({
- label: b.shortName || b.benchmark,
- routerLink: ['/students', studentId, 'goals', goalId, 'benchmarks', b.benchmarkId],
- }));
- }
-
- // *****************************************************************
- // Walks the sidebar tree and expands any node whose routerLink is
- // a prefix of the current URL. Triggers lazy loading if needed.
- // Returns true if the current URL matches or is a descendant of
- // any node in the given list.
- // *****************************************************************
- private async expandToRoute(url: string, nodes?: SidebarNode[]): Promise {
- const tree = nodes || this.sidebarTree();
- let matched = false;
-
- for (const node of tree) {
- const nodePath = node.routerLink ? node.routerLink.join('/') : '';
-
- // Check if this node is the target or an ancestor of the target.
- const isMatch = nodePath !== '' && url === nodePath;
- const isAncestor = nodePath !== '' && url.startsWith(nodePath + '/');
-
- if (isMatch || isAncestor) {
- matched = true;
-
- if (isAncestor) {
- // Expand this node to reveal children.
- if (node.loadChildren && !node.children) {
- node.children = await node.loadChildren();
- }
- node.expanded = true;
-
- // Continue down the tree.
- if (node.children) {
- await this.expandToRoute(url, node.children);
- }
- }
- }
+ onToggleScope() {
+ this.showAll.update(v => !v);
+ this.loadStudents();
}
- return matched;
- }
+ onAddStudent() {
+ this.showAddStudentModal.set(true);
+ }
+
+ onStudentCreated(student: StudentCardDto) {
+ this.showAddStudentModal.set(false);
+ this.studentService.notifyDataChanged();
+ this.selectedStudentId.set(student.studentId);
+ this.router.navigate(['/students', student.studentId]);
+ }
+
+ onEditStudent(student: StudentCardDto, event: Event) {
+ event.stopPropagation();
+ this.editingStudent.set(student);
+ }
+
+ onEditStudentSaved() {
+ this.editingStudent.set(null);
+ this.loadStudents();
+ }
+
+ onLogout() {
+ this.auth.logout().subscribe();
+ this.auth.forceLogout();
+ }
+
+ // ************************ Formatting Helpers **********************
+
+ formatDate(d: Date | null): string {
+ if (!d) return '';
+ return new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
+ }
+
+ // ********************** Support Procedures ***********************
+
+ private loadStudents() {
+ const scope = this.showAll() ? 'all' : undefined;
+ this.studentService.getMyStudents(scope).then(data => {
+ if (data.success) {
+ const sorted = (data.payload || []).sort((a, b) =>
+ a.identifier.localeCompare(b.identifier, undefined, { sensitivity: 'base' })
+ );
+ this.students.set(sorted);
+ }
+ });
+ }
}
-
diff --git a/ui/winstudentgoaltracker/src/app/shared/classes/category-colors.ts b/ui/winstudentgoaltracker/src/app/shared/classes/category-colors.ts
new file mode 100644
index 0000000..424d42b
--- /dev/null
+++ b/ui/winstudentgoaltracker/src/app/shared/classes/category-colors.ts
@@ -0,0 +1,22 @@
+export interface CategoryColor {
+ bg: string;
+ border: string;
+ text: string;
+ accent: string;
+}
+
+const CATEGORY_COLORS: Record = {
+ Reading: { bg: '#EEF2FF', border: '#818CF8', text: '#4338CA', accent: '#6366F1' },
+ Math: { bg: '#FFF7ED', border: '#FB923C', text: '#C2410C', accent: '#F97316' },
+ Writing: { bg: '#F0FDF4', border: '#4ADE80', text: '#15803D', accent: '#22C55E' },
+ Behavior: { bg: '#FDF4FF', border: '#C084FC', text: '#7E22CE', accent: '#A855F7' },
+ Speech: { bg: '#FFF1F2', border: '#FB7185', text: '#BE123C', accent: '#F43F5E' },
+};
+
+const DEFAULT_COLOR: CategoryColor = {
+ bg: '#F5F5F0', border: '#A0A090', text: '#444', accent: '#666',
+};
+
+export function getCategoryColor(category: string): CategoryColor {
+ return CATEGORY_COLORS[category] ?? DEFAULT_COLOR;
+}
diff --git a/ui/winstudentgoaltracker/src/styles.scss b/ui/winstudentgoaltracker/src/styles.scss
index 614dd62..967d081 100644
--- a/ui/winstudentgoaltracker/src/styles.scss
+++ b/ui/winstudentgoaltracker/src/styles.scss
@@ -1,13 +1,78 @@
-/* You can add global styles to this file, and also import other style files */
+@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&display=swap');
+/* ─── Design Tokens ─── */
+:root {
+ --font-family: 'IBM Plex Sans', 'Segoe UI', sans-serif;
+ --bg-page: #F8F8F6;
+ --bg-surface: #FFFFFF;
+ --bg-hover: #F0F0EC;
+ --border-color: #E5E5E0;
+ --border-muted: #D5D5D0;
+ --text-primary: #1a1a1a;
+ --text-secondary: #555;
+ --text-muted: #888;
+ --text-faint: #999;
+ --text-dim: #bbb;
+ --accent-indigo: #4338CA;
+ --accent-indigo-light: #6366F1;
+ --radius-sm: 4px;
+ --radius-md: 6px;
+ --radius-lg: 8px;
+ --radius-xl: 10px;
+ --radius-2xl: 12px;
+ --shadow-modal: 0 20px 60px rgba(0, 0, 0, 0.2);
+ --shadow-card: 0 1px 3px rgba(0, 0, 0, 0.04);
+ --transition-fast: 0.15s ease;
+ --transition-normal: 0.2s ease;
+}
+
+/* ─── Reset ─── */
html,
body {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
+ font-family: var(--font-family);
+ color: var(--text-primary);
+ background: var(--bg-page);
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
}
input[type="date"] {
font-family: inherit;
+}
+
+/* ─── Focus Styles ─── */
+input:focus,
+textarea:focus,
+select:focus {
+ outline: none;
+ border-color: var(--accent-indigo) !important;
+ box-shadow: 0 0 0 2px rgba(67, 56, 202, 0.12);
+}
+
+/* ─── Scrollbar (subtle) ─── */
+::-webkit-scrollbar {
+ width: 6px;
+}
+
+::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+::-webkit-scrollbar-thumb {
+ background: #d5d5d0;
+ border-radius: 3px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: #bbb;
}
\ No newline at end of file