diff --git a/backend/migrations/00072_create_learning_record_tables.sql b/backend/migrations/00072_create_learning_record_tables.sql new file mode 100644 index 000000000..7534b0907 --- /dev/null +++ b/backend/migrations/00072_create_learning_record_tables.sql @@ -0,0 +1,39 @@ +-- +goose Up +CREATE TABLE learning_record_entries ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + client_id TEXT NOT NULL, + is_draft BOOLEAN NOT NULL DEFAULT FALSE, + step_index INTEGER NOT NULL DEFAULT 0, + ui_phase TEXT NOT NULL DEFAULT 'survey', + editing_entry_id INTEGER REFERENCES learning_record_entries(id) ON DELETE SET NULL, + program_name TEXT NOT NULL DEFAULT '', + completion_date TEXT NOT NULL DEFAULT '', + confidence TEXT NOT NULL DEFAULT '', + summary TEXT NOT NULL DEFAULT '', + top_skills TEXT NOT NULL DEFAULT '[]', + barrier_to_completion TEXT NOT NULL DEFAULT '', + goal_connection TEXT NOT NULL DEFAULT '', + pride TEXT NOT NULL DEFAULT '', + standout_moment TEXT NOT NULL DEFAULT '', + advice_to_peer TEXT NOT NULL DEFAULT '', + challenge_toggle TEXT, + challenge_text TEXT NOT NULL DEFAULT '', + skill_tags_before TEXT NOT NULL DEFAULT '[]', + skill_tags_after TEXT NOT NULL DEFAULT '[]', + skill_reflection TEXT NOT NULL DEFAULT '', + growth_reflection TEXT NOT NULL DEFAULT '', + support_selections TEXT NOT NULL DEFAULT '[]', + next_step_selections TEXT NOT NULL DEFAULT '[]', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_learning_record_entries_user_id + ON learning_record_entries(user_id); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_learning_record_entries_client_id + ON learning_record_entries(user_id, client_id); + +-- +goose Down +DROP TABLE IF EXISTS learning_record_entries; diff --git a/backend/migrations/00073_add_learning_record_feature_flag.sql b/backend/migrations/00073_add_learning_record_feature_flag.sql new file mode 100644 index 000000000..9696bdcca --- /dev/null +++ b/backend/migrations/00073_add_learning_record_feature_flag.sql @@ -0,0 +1,10 @@ +-- +goose Up +-- +goose NO TRANSACTION +ALTER TYPE feature ADD VALUE IF NOT EXISTS 'learning_record'; + +INSERT INTO public.feature_flags (name, enabled) +VALUES ('learning_record', FALSE) +ON CONFLICT (name) DO NOTHING; + +-- +goose Down +DELETE FROM public.feature_flags WHERE name = 'learning_record'; diff --git a/backend/src/database/learning_record.go b/backend/src/database/learning_record.go new file mode 100644 index 000000000..62f7092a5 --- /dev/null +++ b/backend/src/database/learning_record.go @@ -0,0 +1,88 @@ +package database + +import ( + "UnlockEdv2/src/models" + "errors" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +func (db *DB) GetLearningRecordEntries(userID uint) ([]models.LearningRecordEntry, error) { + entries := make([]models.LearningRecordEntry, 0) + if err := db.Where("user_id = ? AND is_draft = false", userID). + Order("created_at DESC"). + Find(&entries).Error; err != nil { + return nil, newGetRecordsDBError(err, "learning_record_entries") + } + return entries, nil +} + +func (db *DB) CreateLearningRecordEntry(entry *models.LearningRecordEntry) error { + if err := db.Create(entry).Error; err != nil { + return newCreateDBError(err, "learning_record_entries") + } + return nil +} + +func (db *DB) UpdateLearningRecordEntry(entry *models.LearningRecordEntry) error { + result := db.Model(entry). + Where("id = ? AND user_id = ?", entry.ID, entry.UserID). + Updates(entry) + if result.Error != nil { + return newUpdateDBError(result.Error, "learning_record_entries") + } + return nil +} + +func (db *DB) DeleteLearningRecordEntry(id, userID uint) error { + if err := db.Where("id = ? AND user_id = ?", id, userID). + Delete(&models.LearningRecordEntry{}).Error; err != nil { + return newDeleteDBError(err, "learning_record_entries") + } + return nil +} + +// GetLearningRecordDraft returns the most recently updated draft for the user, or nil. +func (db *DB) GetLearningRecordDraft(userID uint) (*models.LearningRecordEntry, error) { + var draft models.LearningRecordEntry + err := db.Where("user_id = ? AND is_draft = true", userID). + Order("updated_at DESC"). + First(&draft).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, newGetRecordsDBError(err, "learning_record_entries") + } + return &draft, nil +} + +// UpsertLearningRecordDraft inserts or updates a draft row keyed on (user_id, client_id). +func (db *DB) UpsertLearningRecordDraft(draft *models.LearningRecordEntry) error { + draft.IsDraft = true + if err := db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "user_id"}, {Name: "client_id"}}, + DoUpdates: clause.AssignmentColumns([]string{ + "is_draft", "step_index", "ui_phase", "editing_entry_id", + "program_name", "completion_date", "confidence", "summary", + "top_skills", "barrier_to_completion", "goal_connection", + "pride", "standout_moment", "advice_to_peer", + "challenge_toggle", "challenge_text", + "skill_tags_before", "skill_tags_after", "skill_reflection", + "growth_reflection", "support_selections", "next_step_selections", + "updated_at", + }), + }).Create(draft).Error; err != nil { + return newCreateDBError(err, "learning_record_entries") + } + return nil +} + +func (db *DB) DeleteLearningRecordDraft(userID uint, clientID string) error { + if err := db.Where("user_id = ? AND client_id = ? AND is_draft = true", userID, clientID). + Delete(&models.LearningRecordEntry{}).Error; err != nil { + return newDeleteDBError(err, "learning_record_entries") + } + return nil +} diff --git a/backend/src/handlers/learning_record_handler.go b/backend/src/handlers/learning_record_handler.go new file mode 100644 index 000000000..9ae8385c1 --- /dev/null +++ b/backend/src/handlers/learning_record_handler.go @@ -0,0 +1,115 @@ +package handlers + +import ( + "UnlockEdv2/src/models" + "encoding/json" + "net/http" + "strconv" +) + +func (srv *Server) registerLearningRecordRoutes() []routeDef { + axx := models.LearningRecordAccess + return []routeDef{ + featureRoute("GET /api/learning-record/entries", srv.handleIndexLearningRecordEntries, axx), + featureRoute("POST /api/learning-record/entries", srv.handleCreateLearningRecordEntry, axx), + featureRoute("PUT /api/learning-record/entries/{id}", srv.handleUpdateLearningRecordEntry, axx), + featureRoute("DELETE /api/learning-record/entries/{id}", srv.handleDeleteLearningRecordEntry, axx), + featureRoute("GET /api/learning-record/draft", srv.handleGetLearningRecordDraft, axx), + featureRoute("PUT /api/learning-record/draft", srv.handleUpsertLearningRecordDraft, axx), + featureRoute("DELETE /api/learning-record/draft", srv.handleDeleteLearningRecordDraft, axx), + } +} + +func (srv *Server) handleIndexLearningRecordEntries(w http.ResponseWriter, r *http.Request, log sLog) error { + userID := r.Context().Value(ClaimsKey).(*Claims).UserID + entries, err := srv.Db.GetLearningRecordEntries(userID) + if err != nil { + log.add("user_id", userID) + return newDatabaseServiceError(err) + } + return writeJsonResponse(w, http.StatusOK, entries) +} + +func (srv *Server) handleCreateLearningRecordEntry(w http.ResponseWriter, r *http.Request, log sLog) error { + var entry models.LearningRecordEntry + if err := json.NewDecoder(r.Body).Decode(&entry); err != nil { + return newJSONReqBodyServiceError(err) + } + entry.UserID = r.Context().Value(ClaimsKey).(*Claims).UserID + if err := srv.Db.CreateLearningRecordEntry(&entry); err != nil { + log.add("user_id", entry.UserID) + return newDatabaseServiceError(err) + } + return writeJsonResponse(w, http.StatusCreated, entry) +} + +func (srv *Server) handleUpdateLearningRecordEntry(w http.ResponseWriter, r *http.Request, log sLog) error { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + return newInvalidIdServiceError(err, "entry ID") + } + var entry models.LearningRecordEntry + if err := json.NewDecoder(r.Body).Decode(&entry); err != nil { + return newJSONReqBodyServiceError(err) + } + entry.ID = uint(id) + entry.UserID = r.Context().Value(ClaimsKey).(*Claims).UserID + if err := srv.Db.UpdateLearningRecordEntry(&entry); err != nil { + log.add("entry_id", id) + log.add("user_id", entry.UserID) + return newDatabaseServiceError(err) + } + return writeJsonResponse(w, http.StatusOK, entry) +} + +func (srv *Server) handleDeleteLearningRecordEntry(w http.ResponseWriter, r *http.Request, log sLog) error { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + return newInvalidIdServiceError(err, "entry ID") + } + userID := r.Context().Value(ClaimsKey).(*Claims).UserID + if err := srv.Db.DeleteLearningRecordEntry(uint(id), userID); err != nil { + log.add("entry_id", id) + log.add("user_id", userID) + return newDatabaseServiceError(err) + } + return writeJsonResponse(w, http.StatusOK, "Entry deleted successfully") +} + +func (srv *Server) handleGetLearningRecordDraft(w http.ResponseWriter, r *http.Request, log sLog) error { + userID := r.Context().Value(ClaimsKey).(*Claims).UserID + draft, err := srv.Db.GetLearningRecordDraft(userID) + if err != nil { + log.add("user_id", userID) + return newDatabaseServiceError(err) + } + return writeJsonResponse(w, http.StatusOK, draft) +} + +func (srv *Server) handleUpsertLearningRecordDraft(w http.ResponseWriter, r *http.Request, log sLog) error { + var draft models.LearningRecordEntry + if err := json.NewDecoder(r.Body).Decode(&draft); err != nil { + return newJSONReqBodyServiceError(err) + } + draft.UserID = r.Context().Value(ClaimsKey).(*Claims).UserID + if err := srv.Db.UpsertLearningRecordDraft(&draft); err != nil { + log.add("user_id", draft.UserID) + log.add("client_id", draft.ClientID) + return newDatabaseServiceError(err) + } + return writeJsonResponse(w, http.StatusOK, draft) +} + +func (srv *Server) handleDeleteLearningRecordDraft(w http.ResponseWriter, r *http.Request, log sLog) error { + userID := r.Context().Value(ClaimsKey).(*Claims).UserID + clientID := r.URL.Query().Get("client_id") + if clientID == "" { + return newBadRequestServiceError(nil, "client_id query parameter is required") + } + if err := srv.Db.DeleteLearningRecordDraft(userID, clientID); err != nil { + log.add("user_id", userID) + log.add("client_id", clientID) + return newDatabaseServiceError(err) + } + return writeJsonResponse(w, http.StatusOK, "Draft deleted successfully") +} diff --git a/backend/src/handlers/server.go b/backend/src/handlers/server.go index 7f45e2e1e..901d33869 100644 --- a/backend/src/handlers/server.go +++ b/backend/src/handlers/server.go @@ -110,6 +110,7 @@ func (srv *Server) RegisterRoutes() { srv.registerOpenContentActivityRoutes, srv.registerTagRoutes, srv.registerReportsRoutes, + srv.registerLearningRecordRoutes, } { srv.register(route) } diff --git a/backend/src/models/feature_flags.go b/backend/src/models/feature_flags.go index 2a933c181..6f10c6412 100644 --- a/backend/src/models/feature_flags.go +++ b/backend/src/models/feature_flags.go @@ -21,9 +21,10 @@ type ( ) const ( - OpenContentAccess FeatureAccess = "open_content" - ProviderAccess FeatureAccess = "provider_platforms" - ProgramAccess FeatureAccess = "program_management" + OpenContentAccess FeatureAccess = "open_content" + ProviderAccess FeatureAccess = "provider_platforms" + ProgramAccess FeatureAccess = "program_management" + LearningRecordAccess FeatureAccess = "learning_record" // these are the page level features RequestContentAccess FeatureAccess = "request_content" @@ -31,7 +32,7 @@ const ( UploadVideoAccess FeatureAccess = "upload_video" ) -var AllFeatures = []FeatureAccess{OpenContentAccess, ProviderAccess, ProgramAccess, RequestContentAccess, HelpfulLinksAccess, UploadVideoAccess} +var AllFeatures = []FeatureAccess{OpenContentAccess, ProviderAccess, ProgramAccess, LearningRecordAccess, RequestContentAccess, HelpfulLinksAccess, UploadVideoAccess} func Feature(kinds ...FeatureAccess) []FeatureAccess { return kinds diff --git a/backend/src/models/learning_record.go b/backend/src/models/learning_record.go new file mode 100644 index 000000000..c99b8e591 --- /dev/null +++ b/backend/src/models/learning_record.go @@ -0,0 +1,63 @@ +package models + +import ( + "database/sql/driver" + "encoding/json" + "time" +) + +// StringSlice persists a Go string slice as a JSON text column. +type StringSlice []string + +func (s StringSlice) Value() (driver.Value, error) { + if s == nil { + return "[]", nil + } + b, err := json.Marshal(s) + return string(b), err +} + +func (s *StringSlice) Scan(value any) error { + var raw string + switch v := value.(type) { + case string: + raw = v + case []byte: + raw = string(v) + default: + raw = "[]" + } + return json.Unmarshal([]byte(raw), s) +} + +type LearningRecordEntry struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"not null" json:"user_id"` + ClientID string `gorm:"not null" json:"client_id"` + IsDraft bool `gorm:"not null;default:false" json:"is_draft"` + StepIndex int ` json:"step_index"` + UiPhase string ` json:"ui_phase"` + EditingEntryID *uint ` json:"editing_entry_id"` + ProgramName string ` json:"program_name"` + CompletionDate string ` json:"completion_date"` + Confidence string ` json:"confidence"` + Summary string ` json:"summary"` + TopSkills StringSlice `gorm:"type:text" json:"top_skills"` + BarrierToCompletion string ` json:"barrier_to_completion"` + GoalConnection string ` json:"goal_connection"` + Pride string ` json:"pride"` + StandoutMoment string ` json:"standout_moment"` + AdviceToPeer string ` json:"advice_to_peer"` + ChallengeToggle *string ` json:"challenge_toggle"` + ChallengeText string ` json:"challenge_text"` + SkillTagsBefore StringSlice `gorm:"type:text" json:"skill_tags_before"` + SkillTagsAfter StringSlice `gorm:"type:text" json:"skill_tags_after"` + SkillReflection string ` json:"skill_reflection"` + GrowthReflection string ` json:"growth_reflection"` + SupportSelections StringSlice `gorm:"type:text" json:"support_selections"` + NextStepSelections StringSlice `gorm:"type:text" json:"next_step_selections"` + CreatedAt time.Time ` json:"created_at"` + UpdatedAt time.Time ` json:"updated_at"` +} + +func (LearningRecordEntry) TableName() string { return "learning_record_entries" } diff --git a/config/dev.nginx.conf b/config/dev.nginx.conf index 193f974bb..7c0273a7a 100644 --- a/config/dev.nginx.conf +++ b/config/dev.nginx.conf @@ -4,9 +4,10 @@ server { proxy_headers_hash_bucket_size 256; access_log /var/log/nginx/access.log; error_log /var/log/nginx/error.log; + resolver 127.0.0.11 ipv6=off valid=10s; location /api/ { - proxy_pass http://server:8080; + proxy_pass http://host.docker.internal:8080; proxy_buffering on; proxy_cache_methods GET HEAD; proxy_http_version 1.1; @@ -62,7 +63,7 @@ server { } location / { - proxy_pass http://frontend:5173; + proxy_pass http://host.docker.internal:5173; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; diff --git a/docker-compose.yml b/docker-compose.yml index a98278b61..859818061 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -48,6 +48,8 @@ services: volumes: - ./config/dev.nginx.conf:/etc/nginx/conf.d/default.conf - logs:/var/log/nginx/ + extra_hosts: + - "host.docker.internal:host-gateway" networks: - intranet restart: unless-stopped diff --git a/frontend/package.json b/frontend/package.json index 80706d92d..7b84c2df3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -46,7 +46,10 @@ "date-fns": "^4.1.0", "date-fns-tz": "^3.2.0", "embla-carousel-react": "^8.6.0", + "html2canvas": "^1.4.1", + "html2pdf.js": "^0.14.0", "input-otp": "^1.4.2", + "jspdf": "^4.2.1", "lucide-react": "^0.487.0", "moment": "^2.30.1", "next-themes": "^0.4.6", diff --git a/frontend/src/api/learningRecord.ts b/frontend/src/api/learningRecord.ts new file mode 100644 index 000000000..2a676fe2d --- /dev/null +++ b/frontend/src/api/learningRecord.ts @@ -0,0 +1,177 @@ +import API from './api'; +import type { TranscriptDraft, TranscriptEntry, TranscriptQ4Toggle } from '@/types/digital-transcript'; + +interface BackendEntry { + id: number; + client_id: string; + program_name: string; + completion_date: string; + confidence: string; + summary: string; + top_skills: string[]; + barrier_to_completion: string; + goal_connection: string; + pride: string; + standout_moment: string; + advice_to_peer: string; + challenge_toggle: string | null; + challenge_text: string; + skill_tags_before: string[]; + skill_tags_after: string[]; + skill_reflection: string; + growth_reflection: string; + support_selections: string[]; + next_step_selections: string[]; + created_at: string; +} + +function arr(v: unknown): string[] { + return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []; +} + +function toFrontend(b: BackendEntry): TranscriptEntry { + return { + id: b.client_id, + createdAt: b.created_at, + programName: b.program_name, + completionDate: b.completion_date, + confidence: b.confidence, + oneSentence: b.summary, + topSkills: arr(b.top_skills), + whatMadeYouFinish: b.barrier_to_completion, + goalConnection: b.goal_connection, + pride: b.pride, + standoutMoment: b.standout_moment, + adviceToPeer: b.advice_to_peer, + q4Toggle: (b.challenge_toggle as TranscriptQ4Toggle | null) ?? null, + q4Text: b.challenge_text ?? '', + q5BeforeTags: arr(b.skill_tags_before), + q5AfterTags: arr(b.skill_tags_after), + q5FreeText: b.skill_reflection ?? '', + q7Text: b.growth_reflection ?? '', + q8Selections: arr(b.support_selections), + q9Selections: arr(b.next_step_selections) + }; +} + +function toDraftFrontend(b: BackendEntry, updatedAt: string): TranscriptDraft { + return { + id: b.client_id, + updatedAt, + stepIndex: 0, + uiPhase: 'survey', + programName: b.program_name, + completionDate: b.completion_date, + confidence: b.confidence, + oneSentence: b.summary, + topSkills: arr(b.top_skills), + whatMadeYouFinish: b.barrier_to_completion, + goalConnection: b.goal_connection, + pride: b.pride, + standoutMoment: b.standout_moment, + adviceToPeer: b.advice_to_peer, + q4Toggle: (b.challenge_toggle as TranscriptQ4Toggle | null) ?? null, + q4Text: b.challenge_text ?? '', + q5BeforeTags: arr(b.skill_tags_before), + q5AfterTags: arr(b.skill_tags_after), + q5FreeText: b.skill_reflection ?? '', + q7Text: b.growth_reflection ?? '', + q8Selections: arr(b.support_selections), + q9Selections: arr(b.next_step_selections), + editingEntryId: undefined + }; +} + +function toBackend(clientId: string, e: TranscriptEntry | TranscriptDraft) { + return { + client_id: clientId, + program_name: e.programName, + completion_date: e.completionDate, + confidence: e.confidence, + summary: e.oneSentence, + top_skills: e.topSkills, + barrier_to_completion: e.whatMadeYouFinish, + goal_connection: e.goalConnection, + pride: e.pride, + standout_moment: e.standoutMoment, + advice_to_peer: e.adviceToPeer, + challenge_toggle: e.q4Toggle, + challenge_text: e.q4Text, + skill_tags_before: e.q5BeforeTags, + skill_tags_after: e.q5AfterTags, + skill_reflection: e.q5FreeText, + growth_reflection: e.q7Text, + support_selections: e.q8Selections, + next_step_selections: e.q9Selections + }; +} + +function extractArray(resp: Awaited>>): BackendEntry[] { + if (!resp.success) return []; + if (resp.type === 'many') return resp.data as BackendEntry[]; + if (Array.isArray(resp.data)) return resp.data as BackendEntry[]; + return []; +} + +export async function apiGetEntries(): Promise<{ + entries: TranscriptEntry[]; + backendIds: Map; +}> { + const resp = await API.get('learning-record/entries'); + const rows = extractArray(resp); + const entries: TranscriptEntry[] = []; + const backendIds = new Map(); + for (const b of rows) { + entries.push(toFrontend(b)); + backendIds.set(b.client_id, b.id); + } + return { entries, backendIds }; +} + +export async function apiCreateEntry( + entry: TranscriptEntry +): Promise<{ entry: TranscriptEntry; backendId: number } | null> { + const resp = await API.post>( + 'learning-record/entries', + toBackend(entry.id, entry) + ); + if (!resp.success || resp.type !== 'one' || !resp.data) return null; + const b = resp.data as BackendEntry; + return { entry: toFrontend(b), backendId: b.id }; +} + +export async function apiUpdateEntry(backendId: number, entry: TranscriptEntry): Promise { + const resp = await API.put>( + `learning-record/entries/${backendId}`, + toBackend(entry.id, entry) + ); + return resp.success; +} + +export async function apiDeleteEntry(backendId: number): Promise { + const resp = await API.delete(`learning-record/entries/${backendId}`); + return resp.success; +} + +export async function apiGetDraft(): Promise { + const resp = await API.get('learning-record/draft'); + if (!resp.success || resp.type !== 'one' || !resp.data) return null; + const b = resp.data as (BackendEntry & { updated_at: string }) | null; + if (!b || !b.client_id) return null; + return toDraftFrontend(b, b.updated_at ?? new Date().toISOString()); +} + +export async function apiUpsertDraft(draft: TranscriptDraft): Promise { + const resp = await API.put>( + 'learning-record/draft', + toBackend(draft.id, draft) + ); + return resp.success; +} + +export async function apiDeleteDraft(clientId: string): Promise { + const resp = await API.delete( + `learning-record/draft?client_id=${encodeURIComponent(clientId)}` + ); + return resp.success; +} diff --git a/frontend/src/auth/useAuth.ts b/frontend/src/auth/useAuth.ts index 623e964b4..081273261 100644 --- a/frontend/src/auth/useAuth.ts +++ b/frontend/src/auth/useAuth.ts @@ -194,7 +194,10 @@ const getAdminLink = (): string => { }; const getResidentLink = (user: User): string => { - if (user.feature_access.includes(FeatureAccess.OpenContentAccess)) { + if ( + user.feature_access.includes(FeatureAccess.OpenContentAccess) || + user.feature_access.includes(FeatureAccess.LearningRecordAccess) + ) { return '/home'; } if (user.feature_access.includes(FeatureAccess.ProgramAccess)) { diff --git a/frontend/src/components/UnlockEdTour.tsx b/frontend/src/components/UnlockEdTour.tsx index a5b73a33e..063ecfe2d 100644 --- a/frontend/src/components/UnlockEdTour.tsx +++ b/frontend/src/components/UnlockEdTour.tsx @@ -77,7 +77,7 @@ export default function UnlockEdTour() { }); navigate('/knowledge-center'); return; - case '#top-content': + case '#popular-content': setTourState({ stepIndex: targetToStepIndexMap['#navigate-homepage'], diff --git a/frontend/src/components/dashboard/ContinueLearningSection.tsx b/frontend/src/components/dashboard/ContinueLearningSection.tsx new file mode 100644 index 000000000..9821563b2 --- /dev/null +++ b/frontend/src/components/dashboard/ContinueLearningSection.tsx @@ -0,0 +1,217 @@ +import { useNavigate } from 'react-router-dom'; +import { BookOpen, Video } from 'lucide-react'; +import { OpenContentItem } from '@/types'; +import { Card, CardContent } from '@/components/ui/card'; +import { cn } from '@/lib/utils'; + +export interface ContinueLearningItem { + id: string; + title: string; + statusLabel: string; + contentType: string; + contentTypeLabel: string; + durationLabel?: string; + progressPercent: number; + href: string; + external?: boolean; + thumbnailUrl?: string | null; +} + +const PREVIEW_ITEMS: ContinueLearningItem[] = [ + { + id: 'preview-resume-writing', + title: 'Resume Writing Basics', + statusLabel: 'Your progress', + contentType: 'video', + contentTypeLabel: 'Video', + durationLabel: '12 min', + progressPercent: 60, + href: '/knowledge-center' + }, + { + id: 'preview-interview-skills', + title: 'Interview Skills', + statusLabel: 'Not started yet', + contentType: 'library', + contentTypeLabel: 'Article', + durationLabel: '8 min', + progressPercent: 0, + href: '/knowledge-center' + } +]; + +function contentTypeLabel(contentType: string): string { + switch (contentType) { + case 'video': + return 'Video'; + case 'library': + return 'Article'; + case 'helpful_link': + return 'Link'; + default: + return 'Resource'; + } +} + +function resolveContentHref(item: OpenContentItem): { + href: string; + external: boolean; +} { + if (item.content_type === 'video') { + return { + href: `/viewer/videos/${item.content_id}`, + external: false + }; + } + if (item.content_type === 'library') { + return { + href: `/viewer/libraries/${item.content_id}`, + external: false + }; + } + return { href: item.url, external: true }; +} + +export function mapOpenContentToContinueItems( + items: OpenContentItem[] +): ContinueLearningItem[] { + return items.slice(0, 2).map((item, index) => { + const { href, external } = resolveContentHref(item); + return { + id: `${item.content_id}-${item.content_type}`, + title: item.title, + statusLabel: index === 0 ? 'Your progress' : 'Not started yet', + contentType: item.content_type, + contentTypeLabel: contentTypeLabel(item.content_type), + progressPercent: 0, + href, + external, + thumbnailUrl: item.thumbnail_url + }; + }); +} + +function ContentThumbnail({ item }: { item: ContinueLearningItem }) { + if (item.thumbnailUrl) { + return ( + + ); + } + + const Icon = item.contentType === 'video' ? Video : BookOpen; + + return ( +
+ +
+ ); +} + +function ContinueLearningRow({ item }: { item: ContinueLearningItem }) { + const navigate = useNavigate(); + const metaLabel = item.durationLabel + ? `${item.contentTypeLabel} · ${item.durationLabel}` + : item.contentTypeLabel; + const clampedProgress = Math.min(100, Math.max(0, item.progressPercent)); + + const handleClick = () => { + if (item.external) { + window.open(item.href, '_blank', 'noopener,noreferrer'); + return; + } + navigate(item.href); + }; + + return ( + + ); +} + +export interface ContinueLearningSectionProps { + items: ContinueLearningItem[]; +} + +export default function ContinueLearningSection({ + items +}: ContinueLearningSectionProps) { + const displayItems = items.length > 0 ? items : PREVIEW_ITEMS; + + return ( + + ); +} diff --git a/frontend/src/components/dashboard/DiscoverContentSection.tsx b/frontend/src/components/dashboard/DiscoverContentSection.tsx new file mode 100644 index 000000000..78e77eec3 --- /dev/null +++ b/frontend/src/components/dashboard/DiscoverContentSection.tsx @@ -0,0 +1,352 @@ +import { Link, useNavigate } from 'react-router-dom'; +import { ArrowRight, BookOpen, ExternalLink, Star, Video } from 'lucide-react'; +import { HelpfulLink, OpenContentItem, ServerResponseOne } from '@/types'; +import { Card, CardContent } from '@/components/ui/card'; +import { cn } from '@/lib/utils'; +import API from '@/api/api'; + +// ----------------------------------------------------------------------------- +// Discover section — Scenario A (Knowledge Center enabled) only +// ----------------------------------------------------------------------------- + +export interface DiscoverContentItem { + id: string; + title: string; + description: string | null; + contentType: string; + href: string; + external?: boolean; + thumbnailUrl?: string | null; + /** Set for helpful links so clicks can be tracked before opening externally. */ + helpfulLinkId?: number; +} + +export interface DiscoverContentSectionProps { + /** Scenario A — entire section renders only when true (Knowledge Center enabled). */ + scenarioA: boolean; + /** Emphasize the primary discover grid for first-time residents. */ + isNewResident: boolean; + /** Curated discover items (3–4 max); primary Tier 2 content. */ + discoverItems: DiscoverContentItem[]; + /** + * Admin-flagged items for this resident; Tier 3 — minimal pointer only. + * Can be relocated into the Knowledge Center and omitted from the homepage. + */ + flaggedContent: DiscoverContentItem[]; + /** Helpful links merged into the discover content grid. */ + helpfulLinks: HelpfulLink[]; +} + +const PREVIEW_DISCOVER_ITEMS: DiscoverContentItem[] = [ + { + id: 'preview-ged-prep', + title: 'GED Prep Basics', + description: 'Study guides and practice for your high school equivalency exam.', + contentType: 'library', + href: '/knowledge-center' + }, + { + id: 'preview-job-search', + title: 'Job Search 101', + description: 'Tips for resumes, interviews, and finding work after release.', + contentType: 'video', + href: '/knowledge-center' + }, + { + id: 'preview-financial', + title: 'Financial Literacy', + description: 'Budgeting, banking, and building credit on the outside.', + contentType: 'library', + href: '/knowledge-center' + }, + { + id: 'preview-wellness', + title: 'Health & Wellness', + description: 'Short videos on stress, sleep, and staying healthy.', + contentType: 'video', + href: '/knowledge-center' + } +]; + +function openContentKey(item: Pick) { + return `${item.content_type}-${item.content_id}`; +} + +function resolveContentHref(item: OpenContentItem): { + href: string; + external: boolean; +} { + if (item.content_type === 'video') { + return { + href: `/viewer/videos/${item.content_id}`, + external: false + }; + } + if (item.content_type === 'library') { + return { + href: `/viewer/libraries/${item.content_id}`, + external: false + }; + } + return { href: item.url, external: true }; +} + +export function mapOpenContentToDiscoverItems( + items: OpenContentItem[], + max = 4 +): DiscoverContentItem[] { + return items.slice(0, max).map((item) => { + const { href, external } = resolveContentHref(item); + return { + id: openContentKey(item), + title: item.title, + description: item.description?.trim() || item.provider_name?.trim() || null, + contentType: item.content_type, + href, + external, + thumbnailUrl: item.thumbnail_url + }; + }); +} + +function mapHelpfulLinksToDiscoverItems(links: HelpfulLink[]): DiscoverContentItem[] { + return links.map((link) => ({ + id: `helpful-link-${link.id}`, + title: link.title, + description: link.description?.trim() || null, + contentType: 'helpful_link', + href: link.url, + external: true, + thumbnailUrl: link.thumbnail_url || null, + helpfulLinkId: link.id + })); +} + +function ContentThumbnail({ + item, + className +}: { + item: DiscoverContentItem; + className?: string; +}) { + if (item.thumbnailUrl) { + return ( + + ); + } + + const Icon = + item.contentType === 'video' + ? Video + : item.contentType === 'helpful_link' + ? ExternalLink + : BookOpen; + + return ( +
+ +
+ ); +} + +function DiscoverContentCard({ item }: { item: DiscoverContentItem }) { + const navigate = useNavigate(); + + const handleClick = async () => { + if (item.helpfulLinkId != null) { + const resp = (await API.put<{ url: string }, object>( + `helpful-links/activity/${item.helpfulLinkId}`, + {} + )) as ServerResponseOne<{ url: string }>; + const url = resp.success && resp.data?.url ? resp.data.url : item.href; + window.open(url, '_blank', 'noopener,noreferrer'); + return; + } + if (item.external) { + window.open(item.href, '_blank', 'noopener,noreferrer'); + return; + } + navigate(item.href); + }; + + return ( + + ); +} + +/** + * Tier 2/3 Knowledge Center discovery — subordinate to Learning Records. + * Renders nothing in Scenario B or when all sub-sections lack data. + */ +export default function DiscoverContentSection({ + scenarioA, + isNewResident, + discoverItems, + flaggedContent, + helpfulLinks +}: DiscoverContentSectionProps) { + /** Design preview when API data is empty — replaced by live data when available. */ + const displayDiscoverItems = + discoverItems.length > 0 ? discoverItems : PREVIEW_DISCOVER_ITEMS; + + /** Show admin-flagged pointer only when flagged items exist. */ + const hasFlaggedContent = flaggedContent.length > 0; + + const helpfulLinkItems = mapHelpfulLinksToDiscoverItems(helpfulLinks); + const hasHelpfulLinks = helpfulLinkItems.length > 0; + + const hasDiscoverContent = displayDiscoverItems.length > 0; + + if (!scenarioA) { + return null; + } + + if (!hasDiscoverContent && !hasFlaggedContent && !hasHelpfulLinks) { + return null; + } + + const usingDiscoverPreview = discoverItems.length === 0; + + const showPrimaryDiscover = + hasDiscoverContent && (isNewResident || usingDiscoverPreview); + + const showDiscoverCard = showPrimaryDiscover || hasHelpfulLinks; + + return ( +
+ {showDiscoverCard ? ( +
+
+
+

+ Things to explore +

+

+ A few things to look at — go at your own pace. +

+
+ + Go to the Knowledge Center + + +
+ + + + {showPrimaryDiscover + ? displayDiscoverItems.map((item) => ( + + )) + : null} + {hasHelpfulLinks ? ( +

+ Helpful Links +

+ ) : null} + {helpfulLinkItems.map((item) => ( + + ))} +
+
+ {usingDiscoverPreview && showPrimaryDiscover ? ( +

Showing sample discover content

+ ) : null} +
+ ) : ( +
+ Things to explore +
+ )} + + {/* + * Admin-flagged pointer — lowest priority; relocate into Knowledge Center + * when a dedicated flagged feed exists there. + */} + {hasFlaggedContent ? ( + + +
+ +

+ Flagged for you:{' '} + + {flaggedContent[0]?.title} + + {flaggedContent.length > 1 + ? ` +${flaggedContent.length - 1} more` + : null} +

+
+ + View + + +
+
+ ) : null} +
+ ); +} + +export function buildDiscoverSectionData( + topUserContent: OpenContentItem[], + topFacilityContent: OpenContentItem[], + flaggedContent: OpenContentItem[] = [] +) { + const continueKeys = new Set(topUserContent.map(openContentKey)); + const excludeKeys = (item: OpenContentItem) => !continueKeys.has(openContentKey(item)); + + const discoverSource = topFacilityContent.filter(excludeKeys); + const discoverItems = mapOpenContentToDiscoverItems(discoverSource, 4); + + const flaggedItems = mapOpenContentToDiscoverItems(flaggedContent, 3); + + return { discoverItems, flaggedContent: flaggedItems }; +} diff --git a/frontend/src/components/dashboard/IncompleteEntryReminder.tsx b/frontend/src/components/dashboard/IncompleteEntryReminder.tsx new file mode 100644 index 000000000..3b6fa04b3 --- /dev/null +++ b/frontend/src/components/dashboard/IncompleteEntryReminder.tsx @@ -0,0 +1,91 @@ +import { Link } from 'react-router-dom'; +import { PlayCircle } from 'lucide-react'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; +import { + getIncompleteEntryBannerText +} from '@/pages/student/digital-transcript/entryTitleDisplay'; +import { + learningRecordPrimaryButtonClassName +} from '@/pages/student/digital-transcript/learningRecordButtons'; + +/** + * Data for an in-progress achievement entry surfaced on the resident homepage. + * `title` is the raw program / achievement name from storage (may be junk or empty). + * `resumeHref` deep-links into the editor at the step they left off (`?edit=`). + */ +export interface InProgressEntryReminderData { + id: string; + title: string; + resumeHref: string; +} + +export interface IncompleteEntryReminderProps { + /** True when the resident has an unfinished achievement entry on this device. */ + hasInProgressEntry: boolean; + /** In-progress entry props for copy + deep-link; required when `hasInProgressEntry` is true. */ + entry: InProgressEntryReminderData | null; + /** When true, the resident dismissed this reminder for the current entry. */ + dismissed?: boolean; + onDismiss?: () => void; +} + +/** + * Tier-1 banner nudge for returning residents with an incomplete achievement entry. + * This banner is the sole prominent resume entry point on the homepage. + */ +export function IncompleteEntryReminder({ + hasInProgressEntry, + entry, + dismissed = false, + onDismiss +}: IncompleteEntryReminderProps) { + if (!hasInProgressEntry || !entry || dismissed) { + return null; + } + + return ( + + + + You have an unfinished achievement + + + + {getIncompleteEntryBannerText(entry.title)} + +
+ + {onDismiss ? ( + + ) : null} +
+
+
+ ); +} diff --git a/frontend/src/components/dashboard/UpcomingClassSessionCard.tsx b/frontend/src/components/dashboard/UpcomingClassSessionCard.tsx new file mode 100644 index 000000000..3d6c886f3 --- /dev/null +++ b/frontend/src/components/dashboard/UpcomingClassSessionCard.tsx @@ -0,0 +1,142 @@ +import { Link } from 'react-router-dom'; +import { format, isToday, isTomorrow } from 'date-fns'; +import { toZonedTime } from 'date-fns-tz'; +import { ArrowRight, CalendarDays } from 'lucide-react'; +import { FacilityProgramClassEvent } from '@/types'; +import { Skeleton } from '@/components/ui/skeleton'; +import { cn } from '@/lib/utils'; + +const UPCOMING_HORIZON_DAYS = 56; + +function formatCompactTimeRange(start: Date, end: Date): string { + const startFull = start.toLocaleTimeString(undefined, { + hour: 'numeric', + minute: '2-digit', + hour12: true + }); + const endFull = end.toLocaleTimeString(undefined, { + hour: 'numeric', + minute: '2-digit', + hour12: true + }); + + const startPeriod = startFull.match(/\s(AM|PM)$/i)?.[1]; + const endPeriod = endFull.match(/\s(AM|PM)$/i)?.[1]; + const startTime = startFull.replace(/\s(AM|PM)$/i, ''); + const endTime = endFull.replace(/\s(AM|PM)$/i, ''); + + if (startPeriod && startPeriod === endPeriod) { + return `${startTime}–${endTime} ${endPeriod}`; + } + + return `${startFull}–${endFull}`; +} + +function formatSessionDay(zoned: Date): string { + if (isToday(zoned)) return 'Today'; + if (isTomorrow(zoned)) return 'Tomorrow'; + return format(zoned, 'EEEE, MMMM d'); +} + +function formatSessionAtAGlance( + start: Date, + end: Date, + room?: string | null +): string { + const parts = [ + formatSessionDay(start), + formatCompactTimeRange(start, end), + room?.trim() || null + ].filter(Boolean); + + return parts.join(' · '); +} + +export function findNextUpcomingSession( + events: FacilityProgramClassEvent[], + timezone: string +): FacilityProgramClassEvent | null { + const now = new Date(); + const horizon = new Date(now); + horizon.setDate(horizon.getDate() + UPCOMING_HORIZON_DAYS); + + const upcoming = events + .filter((event) => { + if (event.is_cancelled) return false; + const start = new Date(event.start); + const end = new Date(event.end); + if (end < now) return false; + if (start > horizon) return false; + return true; + }) + .sort( + (a, b) => + new Date(a.start).getTime() - new Date(b.start).getTime() + ); + + return upcoming[0] ?? null; +} + +export interface UpcomingClassSessionCardProps { + events: FacilityProgramClassEvent[]; + timezone: string; + isLoading: boolean; +} + +export default function UpcomingClassSessionCard({ + events, + timezone, + isLoading +}: UpcomingClassSessionCardProps) { + const nextSession = findNextUpcomingSession(events, timezone); + + if (isLoading) { + return ( + + ); + } + + if (!nextSession) { + return null; + } + + const start = toZonedTime(new Date(nextSession.start), timezone); + const end = toZonedTime(new Date(nextSession.end), timezone); + const now = new Date(); + const inProgress = + now >= new Date(nextSession.start) && now <= new Date(nextSession.end); + const atAGlance = formatSessionAtAGlance(start, end, nextSession.room); + + return ( + + + + + {nextSession.title} + + · {atAGlance} + + {inProgress ? ( + + Now + + ) : null} + + + ); +} diff --git a/frontend/src/components/learning-record/PrintShareHelpLink.tsx b/frontend/src/components/learning-record/PrintShareHelpLink.tsx new file mode 100644 index 000000000..e55fb2d22 --- /dev/null +++ b/frontend/src/components/learning-record/PrintShareHelpLink.tsx @@ -0,0 +1,64 @@ +import { useState } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog'; +import { + LEARNING_RECORD_PRINT_SHARE_FAQ, + LEARNING_RECORD_SAVED_HERE_FAQ +} from '@/data/learningRecordResidentCopy'; +import { cn } from '@/lib/utils'; + +interface PrintShareHelpLinkProps { + className?: string; + /** For text on dark backgrounds (e.g. home start card). */ + variant?: 'default' | 'onDark'; +} + +export function PrintShareHelpLink({ + className, + variant = 'default' +}: PrintShareHelpLinkProps) { + const [open, setOpen] = useState(false); + + return ( + <> + + + + + + {LEARNING_RECORD_PRINT_SHARE_FAQ.question} + + + {LEARNING_RECORD_PRINT_SHARE_FAQ.answer} + + +
+

+ {LEARNING_RECORD_SAVED_HERE_FAQ.question} +

+

+ {LEARNING_RECORD_SAVED_HERE_FAQ.answer} +

+
+
+
+ + ); +} diff --git a/frontend/src/components/navigation/Sidebar.tsx b/frontend/src/components/navigation/Sidebar.tsx index ef021839e..664d15c9c 100644 --- a/frontend/src/components/navigation/Sidebar.tsx +++ b/frontend/src/components/navigation/Sidebar.tsx @@ -28,7 +28,8 @@ import { ChevronRightIcon, ArrowPathIcon, QuestionMarkCircleIcon, - AdjustmentsHorizontalIcon + AdjustmentsHorizontalIcon, + PencilSquareIcon } from '@heroicons/react/24/outline'; interface SidebarProps { @@ -275,7 +276,9 @@ function StudentNav({ const { tourState } = useTourContext(); if (!user) return null; const hasOpen = hasFeature(user, FeatureAccess.OpenContentAccess); + const hasLearningRecord = hasFeature(user, FeatureAccess.LearningRecordAccess); const hasProgram = hasFeature(user, FeatureAccess.ProgramAccess); + const hasHome = hasOpen || hasLearningRecord; const tourHighlight = (target: string) => tourState.tourActive && tourState.target === target @@ -284,7 +287,7 @@ function StudentNav({ return ( <> - {hasOpen ? ( + {hasHome ? ( ) )} + {hasLearningRecord && ( + + )} {hasOpen && ( void; variant?: 'default' | 'destructive'; + buttonClassName?: string; } export function ConfirmDialog({ @@ -28,7 +30,8 @@ export function ConfirmDialog({ confirmLabel = 'Confirm', cancelLabel = 'Cancel', onConfirm, - variant = 'default' + variant = 'default', + buttonClassName }: ConfirmDialogProps) { return ( @@ -40,13 +43,19 @@ export function ConfirmDialog({ - {cancelLabel} + {cancelLabel} {confirmLabel} diff --git a/frontend/src/components/ui/alert-dialog.tsx b/frontend/src/components/ui/alert-dialog.tsx index 27eb26d78..b1a9d4e80 100644 --- a/frontend/src/components/ui/alert-dialog.tsx +++ b/frontend/src/components/ui/alert-dialog.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'; +import { XIcon } from 'lucide-react'; import { cn } from '@/lib/utils'; import { buttonVariants } from './button'; @@ -53,7 +54,7 @@ AlertDialogOverlay.displayName = 'AlertDialogOverlay'; const AlertDialogContent = React.forwardRef< React.ComponentRef, React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( +>(({ className, children, ...props }, ref) => ( + > + {children} + + + Close + + )); AlertDialogContent.displayName = 'AlertDialogContent'; diff --git a/frontend/src/data/faqData.ts b/frontend/src/data/faqData.ts index 9f2157ab3..4d77b3eae 100644 --- a/frontend/src/data/faqData.ts +++ b/frontend/src/data/faqData.ts @@ -1,3 +1,8 @@ +import { + LEARNING_RECORD_PRINT_SHARE_FAQ, + LEARNING_RECORD_SAVED_HERE_FAQ +} from '@/data/learningRecordResidentCopy'; + interface FAQEntry { question: string; answer: string; @@ -77,6 +82,14 @@ export const FAQ_CATEGORIES: Record = { } ], 'Managing My Account': [ + { + question: LEARNING_RECORD_PRINT_SHARE_FAQ.question, + answer: LEARNING_RECORD_PRINT_SHARE_FAQ.answer + }, + { + question: LEARNING_RECORD_SAVED_HERE_FAQ.question, + answer: LEARNING_RECORD_SAVED_HERE_FAQ.answer + }, { question: 'Can I change my name or username?', answer: 'Talk to the staff and they can help you.' diff --git a/frontend/src/data/learningRecordResidentCopy.ts b/frontend/src/data/learningRecordResidentCopy.ts new file mode 100644 index 000000000..81ede0dab --- /dev/null +++ b/frontend/src/data/learningRecordResidentCopy.ts @@ -0,0 +1,11 @@ +export const LEARNING_RECORD_PRINT_SHARE_FAQ = { + question: 'What does "print or share" mean?', + answer: + 'When you save your record as a PDF, it becomes a single file — like a digital sheet of paper. You can print it out on paper, hand it to a staff member, or send it to someone, such as a case manager or a future employer. Your record stays private and is only seen by people you give it to.' +} as const; + +export const LEARNING_RECORD_SAVED_HERE_FAQ = { + question: 'Where is my record saved?', + answer: + "It's saved right here in the app. It is not shared with anyone automatically. Nothing leaves this app unless you choose to print or share it." +} as const; diff --git a/frontend/src/hooks/useTranscriptDraft.ts b/frontend/src/hooks/useTranscriptDraft.ts new file mode 100644 index 000000000..9548416fe --- /dev/null +++ b/frontend/src/hooks/useTranscriptDraft.ts @@ -0,0 +1,166 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + apiCreateEntry, + apiDeleteDraft, + apiDeleteEntry, + apiGetDraft, + apiGetEntries, + apiUpdateEntry, + apiUpsertDraft +} from '@/api/learningRecord'; +import { TOP_SKILLS_MAX } from '@/pages/student/digital-transcript/transcriptReflectionConfig'; +import { dispatchEntrySessionUpdated } from '@/pages/student/digital-transcript/transcriptEntrySessionStorage'; +import type { TranscriptDraft, TranscriptEntry } from '@/types/digital-transcript'; + +export function createEmptyDraft(): TranscriptDraft { + return { + id: crypto.randomUUID(), + updatedAt: new Date().toISOString(), + stepIndex: 0, + uiPhase: 'survey', + programName: '', + completionDate: '', + confidence: '', + oneSentence: '', + topSkills: [], + whatMadeYouFinish: '', + goalConnection: '', + pride: '', + standoutMoment: '', + adviceToPeer: '', + q4Toggle: null, + q4Text: '', + q5BeforeTags: [], + q5AfterTags: [], + q5FreeText: '', + q7Text: '', + q8Selections: [], + q9Selections: [], + editingEntryId: undefined + }; +} + +const DRAFT_AUTOSAVE_MS = 400; + +export function useTranscriptDraft() { + const [entries, setEntries] = useState([]); + const [draft, setDraft] = useState(null); + const [hydrated, setHydrated] = useState(false); + + // Maps client_id → backend integer id (never exposed to consumers) + const entryBackendIds = useRef(new Map()); + const draftRef = useRef(null); + draftRef.current = draft; + + useEffect(() => { + let cancelled = false; + void (async () => { + const [{ entries: fetched, backendIds }, fetchedDraft] = await Promise.all([ + apiGetEntries(), + apiGetDraft() + ]); + if (cancelled) return; + entryBackendIds.current = backendIds; + setEntries(fetched); + setDraft(fetchedDraft); + setHydrated(true); + })(); + return () => { + cancelled = true; + }; + }, []); + + // Debounced draft autosave + useEffect(() => { + if (!hydrated || !draft) return; + const t = window.setTimeout(() => { + void apiUpsertDraft(draft); + }, DRAFT_AUTOSAVE_MS); + return () => window.clearTimeout(t); + }, [draft, hydrated]); + + const updateDraft = useCallback((patch: Partial) => { + setDraft((prev) => { + const base = prev ?? createEmptyDraft(); + return { ...base, ...patch, updatedAt: new Date().toISOString() }; + }); + }, []); + + const ensureDraft = useCallback((): TranscriptDraft => { + if (draftRef.current) return draftRef.current; + const fresh = createEmptyDraft(); + setDraft(fresh); + return fresh; + }, []); + + const startFreshDraft = useCallback(() => { + const prev = draftRef.current; + if (prev) void apiDeleteDraft(prev.id); + const fresh = createEmptyDraft(); + setDraft(fresh); + return fresh; + }, []); + + const upsertCommittedEntry = useCallback(async (entry: TranscriptEntry) => { + const trimmedEntry = { ...entry, topSkills: entry.topSkills.slice(0, TOP_SKILLS_MAX) }; + const backendId = entryBackendIds.current.get(entry.id); + if (backendId !== undefined) { + await apiUpdateEntry(backendId, trimmedEntry); + setEntries((prev) => prev.map((e) => (e.id === entry.id ? trimmedEntry : e))); + } else { + const result = await apiCreateEntry(trimmedEntry); + if (result) { + entryBackendIds.current.set(entry.id, result.backendId); + setEntries((prev) => { + const idx = prev.findIndex((e) => e.id === entry.id); + if (idx >= 0) { + const next = [...prev]; + next[idx] = result.entry; + return next; + } + return [...prev, result.entry]; + }); + } + } + dispatchEntrySessionUpdated(); + }, []); + + const deleteCommittedEntry = useCallback(async (id: string) => { + const backendId = entryBackendIds.current.get(id); + if (backendId !== undefined) { + await apiDeleteEntry(backendId); + entryBackendIds.current.delete(id); + } + setEntries((prev) => prev.filter((e) => e.id !== id)); + dispatchEntrySessionUpdated(); + }, []); + + const reloadEntries = useCallback(() => { + void (async () => { + const { entries: fetched, backendIds } = await apiGetEntries(); + entryBackendIds.current = backendIds; + setEntries(fetched); + })(); + }, []); + + const persistDraftNow = useCallback(() => { + const d = draftRef.current; + if (d) void apiUpsertDraft(d); + }, []); + + const hasDraft = useMemo(() => draft !== null, [draft]); + + return { + draft, + entries, + hydrated, + hasDraft, + updateDraft, + ensureDraft, + startFreshDraft, + upsertCommittedEntry, + deleteCommittedEntry, + reloadEntries, + persistDraftNow + }; +} diff --git a/frontend/src/loaders/routeLoaders.ts b/frontend/src/loaders/routeLoaders.ts index 77629f00d..f2f35bb4e 100644 --- a/frontend/src/loaders/routeLoaders.ts +++ b/frontend/src/loaders/routeLoaders.ts @@ -44,12 +44,13 @@ function buildClassBreadcrumbs( export const getStudentLevel1Data: LoaderFunction = async ({ request }) => { const user = await fetchUser(); if (!user) return; - const [resourcesResp, userContentResp, facilityContentResp, favoritesResp] = + const [resourcesResp, userContentResp, facilityContentResp, favoritesResp, featuredResp] = await Promise.all([ API.get(`helpful-links?visibility=true&per_page=5`), API.get(`open-content/activity/${user.id}`), API.get(`open-content/activity`), - API.get(`open-content/favorites`) + API.get(`open-content/favorites`), + API.get(`libraries?visibility=featured&order_by=created_at&per_page=3`) ]); const links = resourcesResp.data as HelpfulLinkAndSort; @@ -63,12 +64,27 @@ export const getStudentLevel1Data: LoaderFunction = async ({ request }) => { const favoriteOpenContent = favoritesResp.success ? (favoritesResp.data as OpenContentItem[]) : []; + const featuredLibrariesRaw = featuredResp.success + ? (featuredResp.data as Library[]) + : []; + const featuredLibraries: OpenContentItem[] = featuredLibrariesRaw.map((lib) => ({ + title: lib.title, + url: lib.url, + external_id: lib.external_id, + thumbnail_url: lib.thumbnail_url, + description: lib.description ?? undefined, + visibility_status: lib.visibility_status, + open_content_provider_id: lib.open_content_provider_id, + content_id: lib.id, + content_type: 'library' + })); const libraryOptions = getLibraryOptionsHelper({ request }); return json({ helpfulLinks: helpfulLinks, topUserContent: topUserOpenContent, topFacilityContent: topFacilityOpenContent, favorites: favoriteOpenContent, + featuredLibraries, libraryOptions: libraryOptions }); }; diff --git a/frontend/src/pages/admin/FeatureControl.tsx b/frontend/src/pages/admin/FeatureControl.tsx index ef7b53971..1b5e4646e 100644 --- a/frontend/src/pages/admin/FeatureControl.tsx +++ b/frontend/src/pages/admin/FeatureControl.tsx @@ -36,6 +36,7 @@ export default function FeatureControl() { user.feature_access.includes(feature); const kcEnabled = isEnabled(FeatureAccess.OpenContentAccess); + const lrEnabled = isEnabled(FeatureAccess.LearningRecordAccess); const requestToggle = ( target: ToggleTarget, @@ -188,6 +189,36 @@ export default function FeatureControl() { + {/* Learning Record */} +
+
+
+

+ Learning Record +

+

+ Allows residents to log and track their learning achievements and generate a personal learning record +

+
+ + requestToggle( + { + type: 'feature', + key: FeatureAccess.LearningRecordAccess + }, + 'Learning Record', + lrEnabled + ) + } + /> +
+

+ No additional configuration options +

+
+ {/* Program Hub & Tracking */}
diff --git a/frontend/src/pages/learning/ResidentOnlineCourses.tsx b/frontend/src/pages/learning/ResidentOnlineCourses.tsx new file mode 100644 index 000000000..13067f77c --- /dev/null +++ b/frontend/src/pages/learning/ResidentOnlineCourses.tsx @@ -0,0 +1,114 @@ +import { Navigate, useLoaderData } from 'react-router-dom'; +import { useAuth, hasFeature } from '@/auth/useAuth'; +import { FeatureAccess } from '@/types'; +import type { UserCourses, ActivityMapData } from '@/types'; +import { PageHeader } from '@/components/shared'; +import { UserCoursesStatsGrid } from '@/components/student/UserCoursesStatsGrid'; +import { Card, CardContent } from '@/components/ui/card'; +import { BookOpen } from 'lucide-react'; + +interface LoaderData { + courses: UserCourses[]; + week_activity: ActivityMapData[]; +} + +export default function ResidentOnlineCourses() { + const { user } = useAuth(); + const loaderData = useLoaderData() as LoaderData | undefined; + + if (!user) return null; + + if (!hasFeature(user, FeatureAccess.ProviderAccess)) { + return ; + } + + const courses = loaderData?.courses ?? []; + const summary = { + num_completed: courses.filter((c) => c.course_progress >= 100).length, + num_in_progress: courses.filter( + (c) => c.course_progress > 0 && c.course_progress < 100 + ).length, + total_time: courses.reduce((sum, c) => sum + (c.total_time ?? 0), 0), + courses + }; + + return ( +
+ + + {courses.length > 0 && } + + {courses.length === 0 ? ( +
+ +

+ No online courses found. Courses from connected + providers will appear here. +

+
+ ) : ( +
+ {courses.map((course) => ( + + {course.thumbnail_url && ( + + )} + +
+

+ {course.provider_platform_name} +

+ + {course.course_name} + +
+ +
+
+ Progress + + {Math.floor(course.course_progress)} + % + +
+
+
+
+
+ + {course.grade && ( +

+ Grade:{' '} + + {course.grade} + +

+ )} + + + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/pages/student/ResidentHome.tsx b/frontend/src/pages/student/ResidentHome.tsx index 0c8d1a4a8..e6aff10b1 100644 --- a/frontend/src/pages/student/ResidentHome.tsx +++ b/frontend/src/pages/student/ResidentHome.tsx @@ -1,255 +1,111 @@ -import { useLoaderData, useNavigate } from 'react-router-dom'; +import { useEffect, useMemo } from 'react'; +import { useLoaderData, useSearchParams } from 'react-router-dom'; import useSWR from 'swr'; -import { useEffect } from 'react'; +import { useAuth } from '@/auth/useAuth'; import { OpenContentItem, HelpfulLink, HelpfulLinkAndSort, - Library, ServerResponseMany, ServerResponseOne } from '@/types'; -import TopContentList from '@/components/dashboard/TopContentList'; -import { Card, CardContent } from '@/components/ui/card'; -import { ExternalLink, Star } from 'lucide-react'; -import { EmptyState } from '@/components/shared'; -import { useTourContext } from '@/contexts/useTourContext'; -import { targetToStepIndexMap } from '@/contexts/tourState'; -import { decodeHtmlEntities } from '@/lib/decodeHtmlEntities'; +import { useTranscriptDraft } from '@/hooks/useTranscriptDraft'; +import { + ResidentHomeDashboard, + type ResidentHomeLearningState, + type ResidentHomeScenario +} from '@/pages/student/ResidentHomeDashboard'; + +const LAST_HOME_VISIT_KEY = 'unlocked.resident-home.last-visit'; interface ResidentHomeData { helpfulLinks: HelpfulLink[]; topUserContent: OpenContentItem[]; topFacilityContent: OpenContentItem[]; + featuredLibraries: OpenContentItem[]; favorites: OpenContentItem[]; } -function FeaturedLibraryCard({ - library, - onClick -}: { - library: Library; - onClick: () => void; -}) { - const title = decodeHtmlEntities(library.title); - const description = decodeHtmlEntities(library.description ?? ''); - return ( -
- - -
- {library.thumbnail_url ? ( - {title} - ) : ( -
- )} -

- {title} -

-
-

{description}

- - -
- ); -} - -function HelpfulLinkCard({ link }: { link: HelpfulLink }) { - const title = decodeHtmlEntities(link.title); - const description = decodeHtmlEntities(link.description ?? ''); - return ( - - - - -
-

- {title} -

- {description && ( -

- {description} -

- )} -
-
-
-
- ); +function parsePreviewScenario(raw: string | null): ResidentHomeScenario | undefined { + if (raw === 'a' || raw === 'both') return 'knowledgeCenterAndLearningRecords'; + if (raw === 'b' || raw === 'records') return 'learningRecordsOnly'; + return undefined; } -function FavoriteItem({ item }: { item: OpenContentItem }) { - const navigate = useNavigate(); - const title = decodeHtmlEntities(item.title); - - const handleClick = () => { - if (item.content_type === 'video') { - navigate(`/viewer/videos/${item.content_id}`); - } else if (item.content_type === 'library') { - navigate(`/viewer/libraries/${item.content_id}`); - } else { - window.open(item.url, '_blank', 'noopener,noreferrer'); - } - }; - - return ( -
- {item.thumbnail_url ? ( - {title} - ) : ( -
- )} -

{title}

-
- ); +function parsePreviewLearningState( + raw: string | null +): ResidentHomeLearningState | undefined { + if (raw === 'new') return 'new'; + if (raw === 'incomplete' || raw === 'resume') return 'returningWithIncomplete'; + if (raw === 'complete' || raw === 'returning') return 'returningComplete'; + return undefined; } export default function ResidentHome() { - const navigate = useNavigate(); - const { topUserContent, topFacilityContent } = + const { user } = useAuth(); + const [searchParams] = useSearchParams(); + const { topUserContent, topFacilityContent, featuredLibraries } = useLoaderData() as ResidentHomeData; - const { tourState, setTourState } = useTourContext(); - const { data: featured } = useSWR>( - '/api/libraries?visibility=featured&order_by=created_at' - ); + const { entries, hasDraft, hydrated } = useTranscriptDraft(); + const { data: favorites } = useSWR>( '/api/open-content/favorite-groupings' ); const { data: helpfulLinks } = useSWR>('/api/helpful-links'); + const scenario = parsePreviewScenario(searchParams.get('homeScenario')); + const previewLearningState = parsePreviewLearningState( + searchParams.get('homeState') + ); + const showReflectNudge = + searchParams.get('reflectNudge') === '1' || + searchParams.get('reflectNudge') === 'true'; + + const daysSinceLastVisitOverride = searchParams.get('daysSinceLastVisit'); + const daysSinceLastVisit = useMemo(() => { + if (daysSinceLastVisitOverride !== null) { + const parsed = Number(daysSinceLastVisitOverride); + return Number.isFinite(parsed) ? parsed : undefined; + } + try { + const raw = localStorage.getItem(LAST_HOME_VISIT_KEY); + if (!raw) return 0; + const last = Date.parse(raw); + if (Number.isNaN(last)) return 0; + return Math.floor((Date.now() - last) / (1000 * 60 * 60 * 24)); + } catch { + return undefined; + } + }, [daysSinceLastVisitOverride]); + useEffect(() => { - if (tourState.tourActive && tourState.target === '#navigate-homepage') { - setTourState({ - stepIndex: targetToStepIndexMap['#popular-content'], - target: '#popular-content' - }); - } else if (tourState.tourActive && tourState.stepIndex !== 1) { - setTourState({ - run: true, - stepIndex: 0, - target: '#resident-home' - }); + try { + localStorage.setItem(LAST_HOME_VISIT_KEY, new Date().toISOString()); + } catch { + /* ignore — last-visit used for reflect nudge on the next home load */ } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [tourState.tourActive]); + }, []); + + if (!user || !hydrated) return null; - const featuredItems = featured?.data ?? []; const favoriteItems = favorites?.data ?? []; const links = helpfulLinks?.data?.helpful_links ?? []; return ( -
-
-
- {featuredItems.length > 0 && ( -
-

- Featured Content -

-
- {featuredItems.map((lib) => ( - - navigate( - `/viewer/libraries/${lib.id}` - ) - } - /> - ))} -
-
- )} - -
-

- Pick Up Where You Left Off -

-
-
- - navigate('/knowledge-center') - } - /> -
- -
-
- - {links.length > 0 && ( -
-

Helpful Links

-
- {links.map((link) => ( - - ))} -
-
- )} -
- - -
-
+ ); } diff --git a/frontend/src/pages/student/ResidentHomeDashboard.tsx b/frontend/src/pages/student/ResidentHomeDashboard.tsx new file mode 100644 index 000000000..28bc8f192 --- /dev/null +++ b/frontend/src/pages/student/ResidentHomeDashboard.tsx @@ -0,0 +1,617 @@ +import { Link } from 'react-router-dom'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import useSWR from 'swr'; +import { + PenLine, + Plus, + Star, + X +} from 'lucide-react'; +import { useAuth, hasFeature } from '@/auth/useAuth'; +import { + OpenContentItem, + HelpfulLink, + FeatureAccess, + User, + FacilityProgramClassEvent, + ServerResponseMany +} from '@/types'; +import type { TranscriptEntry } from '@/types/digital-transcript'; +import ContinueLearningSection, { + mapOpenContentToContinueItems +} from '@/components/dashboard/ContinueLearningSection'; +import DiscoverContentSection, { + buildDiscoverSectionData +} from '@/components/dashboard/DiscoverContentSection'; +import { IncompleteEntryReminder } from '@/components/dashboard/IncompleteEntryReminder'; +import UpcomingClassSessionCard from '@/components/dashboard/UpcomingClassSessionCard'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { useTourContext } from '@/contexts/useTourContext'; +import { targetToStepIndexMap } from '@/contexts/tourState'; +import { + DIGITAL_TRANSCRIPT_BASE, + DIGITAL_TRANSCRIPT_ENTRY_PATH, + setDigitalTranscriptStorageContext +} from '@/pages/student/digital-transcript/digitalTranscriptRoutes'; +import { + LEARNING_RECORD_BUTTON_SIZE, + learningRecordOutlineButtonClassName +} from '@/pages/student/digital-transcript/learningRecordButtons'; +import { + findIncompleteAchievementEntry, + sortEntriesNewestFirst +} from '@/pages/student/digital-transcript/transcriptEntrySessionStorage'; +import { entryIsComplete } from '@/pages/student/digital-transcript/learningRecordDocumentModel'; +import { getLearningRecordFormVariant } from '@/pages/student/digital-transcript/learningRecordPrototypes'; +import { learningRecordResidentDisplayName } from '@/pages/student/digital-transcript/learningRecordResidentName'; +import { ViewAllAchievementsSheet } from '@/pages/student/digital-transcript/ViewAllAchievementsSheet'; +import { PrintShareHelpLink } from '@/components/learning-record/PrintShareHelpLink'; + +// ----------------------------------------------------------------------------- +// Scenario & state flags (preview + production defaults) +// ----------------------------------------------------------------------------- + +/** Scenario A: Knowledge Center + Learning Records. Scenario B: Learning Records only. */ +export type ResidentHomeScenario = + | 'knowledgeCenterAndLearningRecords' + | 'learningRecordsOnly'; + +/** + * Learning-record resident state (auto-detected from device storage unless overridden). + * - `new`: no saved achievements, nothing in progress + * - `returningWithIncomplete`: draft or unsynced session work exists + * - `returningComplete`: has saved achievements, no in-progress entry + */ +export type ResidentHomeLearningState = + | 'new' + | 'returningWithIncomplete' + | 'returningComplete'; + +/** Show reflect nudge when days since last visit meets or exceeds this threshold. */ +export const REFLECT_NUDGE_DAYS_THRESHOLD = 14; + +export interface ResidentHomeDashboardProps { + /** Toggle Scenario A vs B for design previews. Defaults from Open Content feature access. */ + scenario?: ResidentHomeScenario; + /** Override auto-detected learning-record state (dev / design preview). */ + previewLearningState?: ResidentHomeLearningState; + /** Force reflect nudge visible (dev preview); production uses `daysSinceLastVisit`. */ + showReflectNudge?: boolean; + /** Days since the resident last visited the homepage; drives reflect nudge when high enough. */ + daysSinceLastVisit?: number; + /** Saved achievements (from `useTranscriptDraft`). */ + learningRecordEntries: TranscriptEntry[]; + /** True when draft or dirty entry session exists (`useTranscriptDraft` `hasDraft`). */ + hasIncompleteEntry: boolean; + topUserContent: OpenContentItem[]; + topFacilityContent: OpenContentItem[]; + featuredLibraries: OpenContentItem[]; + favoriteItems: OpenContentItem[]; + helpfulLinks: HelpfulLink[]; +} + +const REFLECT_NUDGE_STORAGE_KEY = 'unlocked.resident-home.reflect-nudge.dismissed'; + +function achievementCountWord(count: number): string { + return count === 1 ? 'achievement' : 'achievements'; +} + +/** "Monday, June 1" — date line above the greeting. */ +function formatTodayDate(now = new Date()): string { + return now.toLocaleDateString(undefined, { + weekday: 'long', + month: 'long', + day: 'numeric' + }); +} + +/** Time-aware greeting prefix to match the welcome-card pattern. */ +function timeAwareGreeting(now = new Date()): string { + const hour = now.getHours(); + if (hour < 12) return 'Good morning'; + if (hour < 18) return 'Good afternoon'; + return 'Good evening'; +} + +function resolveScenario( + user: User, + scenarioProp?: ResidentHomeScenario +): ResidentHomeScenario { + if (scenarioProp) return scenarioProp; + return hasFeature(user, FeatureAccess.OpenContentAccess) + ? 'knowledgeCenterAndLearningRecords' + : 'learningRecordsOnly'; +} + +function resolveLearningRecordEnabled( + user: User, + scenarioProp?: ResidentHomeScenario +): boolean { + if (scenarioProp) return true; + return hasFeature(user, FeatureAccess.LearningRecordAccess); +} + +function detectLearningState( + entries: TranscriptEntry[], + inProgressEntryExists: boolean, + formVariant: ReturnType +): ResidentHomeLearningState { + const savedCount = entries.filter((e) => entryIsComplete(e, formVariant)).length; + if (inProgressEntryExists) return 'returningWithIncomplete'; + if (savedCount === 0) return 'new'; + return 'returningComplete'; +} + +function FavoriteItem({ item }: { item: OpenContentItem }) { + return ( + + {item.thumbnail_url ? ( + {item.title} + ) : ( +
+ )} +

{item.title}

+
+ ); +} + +function FavoritesPanel({ favoriteItems }: { favoriteItems: OpenContentItem[] }) { + return ( +
+
+ +

Favorites

+
+ {favoriteItems.length > 0 ? ( +
+ {favoriteItems.map((item) => ( + + ))} +
+ ) : ( +
+

No Favorites Yet

+

+ Content you favorite will appear here for quick access. +

+
+ )} +
+ ); +} + +function RecentAchievementsPanel({ + totalCount, + onExportClick, + hasInProgressEntry = false +}: { + totalCount: number; + onExportClick: () => void; + hasInProgressEntry?: boolean; +}) { + return ( + + + {totalCount === 0 ? ( +
+

+ {hasInProgressEntry + ? 'Once you finish your entry, it will show up here.' + : "You haven't added anything yet. What you add will show up here."} +

+
+ ) : ( +

+ + {totalCount} + + + {achievementCountWord(totalCount)} saved here + +

+ )} + + {totalCount > 0 ? ( + + ) : null} +
+
+ ); +} + +export function ResidentHomeDashboard({ + scenario: scenarioProp, + previewLearningState, + showReflectNudge = false, + daysSinceLastVisit, + learningRecordEntries, + hasIncompleteEntry, + topUserContent, + topFacilityContent, + featuredLibraries, + favoriteItems, + helpfulLinks +}: ResidentHomeDashboardProps) { + const { user } = useAuth(); + const { tourState, setTourState } = useTourContext(); + const [reflectNudgeDismissed, setReflectNudgeDismissed] = useState(() => { + try { + return localStorage.getItem(REFLECT_NUDGE_STORAGE_KEY) === '1'; + } catch { + return false; + } + }); + const [entrySessionTick, setEntrySessionTick] = useState(0); + const [exportSheetOpen, setExportSheetOpen] = useState(false); + const [incompleteReminderDismissedId, setIncompleteReminderDismissedId] = + useState(null); + + const learningRecordFormVariant = getLearningRecordFormVariant(DIGITAL_TRANSCRIPT_BASE); + const residentName = learningRecordResidentDisplayName(user); + + useEffect(() => { + setDigitalTranscriptStorageContext(DIGITAL_TRANSCRIPT_BASE); + }, []); + + useEffect(() => { + const bump = () => setEntrySessionTick((n) => n + 1); + window.addEventListener('transcript-entry-session-updated', bump); + return () => + window.removeEventListener('transcript-entry-session-updated', bump); + }, []); + + useEffect(() => { + if (tourState.tourActive && tourState.target === '#navigate-homepage') { + setTourState({ + stepIndex: targetToStepIndexMap['#popular-content'], + target: '#popular-content' + }); + } else if (tourState.tourActive && tourState.stepIndex !== 1) { + setTourState({ + run: true, + stepIndex: 0, + target: '#resident-home' + }); + } + }, [tourState.tourActive, setTourState]); + + const dismissReflectNudge = useCallback(() => { + setReflectNudgeDismissed(true); + try { + localStorage.setItem(REFLECT_NUDGE_STORAGE_KEY, '1'); + } catch { + /* ignore */ + } + }, []); + + // --- Resolved flags (used for layout + conditional rendering) --- + const scenarioFlag = user ? resolveScenario(user, scenarioProp) : 'learningRecordsOnly'; + const knowledgeCenterEnabled = scenarioFlag === 'knowledgeCenterAndLearningRecords'; + const learningRecordEnabled = user ? resolveLearningRecordEnabled(user, scenarioProp) : false; + const hasProgramsHub = + !!user && + (hasFeature(user, FeatureAccess.ProgramAccess) || + hasFeature(user, FeatureAccess.ProviderAccess)); + + const calendarStartDate = useMemo(() => { + const d = new Date(); + d.setDate(d.getDate() - 1); + d.setHours(0, 0, 0, 0); + return d.toISOString(); + }, []); + const calendarEndDate = useMemo(() => { + const d = new Date(); + d.setDate(d.getDate() + 56); + return d.toISOString(); + }, []); + + const { data: calendarResp, isLoading: calendarLoading } = useSWR< + ServerResponseMany + >( + user && hasProgramsHub + ? `/api/student-calendar?start_dt=${calendarStartDate}&end_dt=${calendarEndDate}` + : null + ); + + const calendarEvents = calendarResp?.data ?? []; + const calendarEventsForDisplay = useMemo( + () => calendarEvents, + [calendarEvents] + ); + + const incompleteEntry = useMemo(() => { + return findIncompleteAchievementEntry(learningRecordEntries, learningRecordFormVariant); + }, [hasIncompleteEntry, learningRecordEntries, entrySessionTick, learningRecordFormVariant]); + + /** True only when there is a real session row to resume (never an empty placeholder). */ + const inProgressEntryExists = incompleteEntry !== null; + + // --- In-progress entry reminder (Tier 1) --- + /** True when an incomplete achievement entry exists on this device. */ + const hasInProgressEntry = inProgressEntryExists; + + const resumeHref = incompleteEntry + ? `${DIGITAL_TRANSCRIPT_ENTRY_PATH}?edit=${encodeURIComponent(incompleteEntry.id)}` + : DIGITAL_TRANSCRIPT_ENTRY_PATH; + + /** + * Entry id, display title, and resume URL for the incomplete-entry banner. + * Null when there is nothing to resume. + */ + const inProgressEntryReminder = incompleteEntry + ? { + id: incompleteEntry.id, + title: incompleteEntry.programName, + resumeHref + } + : null; + + const incompleteEntryReminderDismissed = + hasInProgressEntry && + inProgressEntryReminder !== null && + incompleteReminderDismissedId === inProgressEntryReminder.id; + + const dismissIncompleteEntryReminder = useCallback(() => { + if (!inProgressEntryReminder) return; + setIncompleteReminderDismissedId(inProgressEntryReminder.id); + }, [inProgressEntryReminder]); + + const savedEntries = useMemo( + () => + learningRecordEntries.filter((e) => + entryIsComplete(e, learningRecordFormVariant) + ), + [learningRecordEntries, learningRecordFormVariant] + ); + const achievementsNewestFirst = useMemo( + () => sortEntriesNewestFirst(savedEntries), + [savedEntries] + ); + const hasSavedRecords = savedEntries.length > 0; + + /** New resident: zero saved achievements and nothing in progress. */ + const isNewResident = !hasSavedRecords && !inProgressEntryExists; + + const detectedState = detectLearningState( + learningRecordEntries, + inProgressEntryExists, + learningRecordFormVariant + ); + const learningState = previewLearningState ?? detectedState; + + /** First-time vs returning shapes the hero copy/CTA (hero is the start entry point). */ + const heroIsFirstTime = previewLearningState === 'new' || isNewResident; + + const heroCtaLabel = heroIsFirstTime ? 'Start my record' : 'Add an achievement'; + + const continueLearningItems = useMemo( + () => mapOpenContentToContinueItems(topUserContent), + [topUserContent] + ); + + const pickUpIsEmpty = topUserContent.length === 0; + + // --- Discover section (Scenario A / Knowledge Center block) --- + /** Scenario A — Knowledge Center enabled; gates the entire Discover section. */ + const scenarioA = knowledgeCenterEnabled; + + const { discoverItems, flaggedContent } = useMemo( + () => + buildDiscoverSectionData( + topUserContent, + topFacilityContent, + // Admin-flagged feed not yet on homepage API — wire when available. + [] + ), + [topUserContent, topFacilityContent] + ); + + const reflectNudgeByAbsence = + typeof daysSinceLastVisit === 'number' && + daysSinceLastVisit >= REFLECT_NUDGE_DAYS_THRESHOLD; + + const reflectNudgeVisible = + !reflectNudgeDismissed && + learningState !== 'new' && + !isNewResident && + (showReflectNudge || reflectNudgeByAbsence); + + if (!user) return null; + + return ( +
+
+
+ {/* ── Header: date + time-aware greeting + upcoming class chip ─── */} +
+
+

+ {formatTodayDate()} +

+

+ {timeAwareGreeting()}, {user.name_first ?? 'Student'}. +

+
+ {hasProgramsHub ? ( + + ) : null} +
+ + {/* ── Reflect / nudge (dismissible; days-since-last-visit driven) ─ */} + {learningRecordEnabled && reflectNudgeVisible ? ( + + + + Catch up your learning record + + + + It has been a while since you logged an achievement. Add anything + you have completed recently so your record stays current. + +
+ + +
+
+
+ ) : null} + + {/* Tier-1 incomplete-entry banner — sole resume path when an entry is in progress */} + {learningRecordEnabled ? ( + + ) : null} + + {/* ── Tier 1: Learning Records — primary action, front and center ─ */} + {learningRecordEnabled ? ( +
+
+

+ Learning Record +

+

+ Keep track of what you've learned. +

+
+ +
+ {/* Hero: olive-green primary card (white CTA = the single primary) */} + + +
+

+ {heroIsFirstTime + ? 'Start your learning record' + : 'Log a new achievement'} +

+

+ Write down a class, program, or skill you finished. Your + answers are saved here as you go. Nothing leaves this app + unless you choose to print or share it. +

+ +
+
+ +
+
+
+ + setExportSheetOpen(true)} + /> +
+
+ ) : null} + + {/* ── Tier 2 (Scenario A only): Knowledge Center ─ */} + {knowledgeCenterEnabled ? ( +
+ + +
+ ) : null} +
+ + {knowledgeCenterEnabled && !pickUpIsEmpty ? ( + + ) : null} +
+ + {learningRecordEnabled ? ( + + ) : null} +
+ ); +} + +export default ResidentHomeDashboard; diff --git a/frontend/src/pages/student/digital-transcript/AchievementForm.tsx b/frontend/src/pages/student/digital-transcript/AchievementForm.tsx new file mode 100644 index 000000000..3ad512bf9 --- /dev/null +++ b/frontend/src/pages/student/digital-transcript/AchievementForm.tsx @@ -0,0 +1,468 @@ +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Label } from '@/components/ui/label'; +import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; +import { cn } from '@/lib/utils'; +import type { TranscriptEntry } from '@/types/digital-transcript'; +import { AchievementFormMetadata } from './AchievementFormMetadata'; +import { ConfidenceSegmentedControl } from './ConfidenceSegmentedControl'; +import { + learningRecordOutlineButtonClassName, + learningRecordPrimaryButtonClassName, + learningRecordQuestionHeaderClassName, + learningRecordSelectedChoiceClassName, + learningRecordCheckedCheckboxClassName, + learningRecordSelectedToggleClassName, + LEARNING_RECORD_BUTTON_SIZE +} from './learningRecordButtons'; +import { ReflectionTextField } from './ReflectionTextField'; +import { + FUNNEL_FIELD_DESCRIPTIONS, + FUNNEL_FORM_STEP_COUNT, + FUNNEL_FORM_STEPS, + FUNNEL_Q5_AFTER_TAGS, + FUNNEL_Q5_BEFORE_TAGS, + FUNNEL_Q5_TAGS_MAX, + FUNNEL_Q8_OPTIONS, + FUNNEL_Q9_OPTIONS, + FUNNEL_PARAGRAPH_TEXT_NUDGE, + FUNNEL_REFLECTION_TEXT_NUDGES, + funnelStepFieldLabel +} from './transcriptReflectionConfig'; + +const questionLabelClassName = 'text-base font-medium leading-snug text-foreground'; + +function QuestionLabel({ + htmlFor, + children +}: { + htmlFor?: string; + children: React.ReactNode; +}) { + if (htmlFor) { + return ( + + ); + } + return
{children}
; +} + +function FieldDescription({ children }: { children: React.ReactNode }) { + return

{children}

; +} + +function QuestionFieldHeader({ + htmlFor, + label, + description +}: { + htmlFor?: string; + label: React.ReactNode; + description?: React.ReactNode; +}) { + return ( +
+ {label} + {description ? {description} : null} +
+ ); +} + +function TagCloud({ + tags, + selected, + max, + groupLabel, + onChange +}: { + tags: readonly string[]; + selected: string[]; + max: number; + groupLabel: string; + onChange: (next: string[]) => void; +}) { + const atMax = selected.length >= max; + + function toggle(tag: string) { + if (selected.includes(tag)) { + onChange(selected.filter((t) => t !== tag)); + return; + } + if (atMax) return; + onChange([...selected, tag]); + } + + return ( +
+

{groupLabel}

+
+ {tags.map((tag) => { + const isSelected = selected.includes(tag); + const disabled = atMax && !isSelected; + return ( + + ); + })} +
+
+ ); +} + +function ChecklistField({ + options, + selected, + onChange, + idPrefix, + columns = 1 +}: { + options: readonly string[]; + selected: string[]; + onChange: (next: string[]) => void; + idPrefix: string; + columns?: 1 | 2; +}) { + function toggle(option: string) { + if (selected.includes(option)) { + onChange(selected.filter((o) => o !== option)); + } else { + onChange([...selected, option]); + } + } + + return ( +
+ {options.map((option, index) => { + const checked = selected.includes(option); + const optionId = `${idPrefix}-${index}`; + return ( +
+ toggle(option)} + className={learningRecordCheckedCheckboxClassName} + /> + +
+ ); + })} +
+ ); +} + +interface AchievementFormProps { + entry: TranscriptEntry; + onChange: (patch: Partial) => void; + showSaveErrors: boolean; + activeStep: number; + onActiveStepChange: (step: number) => void; + onFinish?: () => void; +} + +function renderStepFields( + stepIndex: number, + entry: TranscriptEntry, + onChange: (patch: Partial) => void, + showSaveErrors: boolean +) { + const id = entry.id; + + switch (stepIndex) { + case 0: + return ( + <> + + onChange({ whatMadeYouFinish: v })} + fieldKey="whatMadeYouFinish" + nudge={FUNNEL_REFLECTION_TEXT_NUDGES.whatMadeYouFinish} + descriptionAboveInput + /> + + ); + case 1: + return ( + <> +
+
+ + { + if (v === 'yes') { + onChange({ q4Toggle: 'yes' }); + } else if (v === 'notReally') { + onChange({ q4Toggle: 'notReally', q4Text: '' }); + } + }} + className="flex w-full gap-2" + > + + Yes + + + Not really + + +
+ {entry.q4Toggle === 'yes' ? ( + onChange({ q4Text: v })} + nudge={FUNNEL_PARAGRAPH_TEXT_NUDGE} + /> + ) : null} +
+ +
+
+ + onChange({ q5BeforeTags })} + /> +
+ onChange({ q5AfterTags })} + /> + onChange({ q5FreeText: v })} + nudge={FUNNEL_PARAGRAPH_TEXT_NUDGE} + /> +
+ + onChange({ adviceToPeer: v })} + fieldKey="adviceToPeer" + nudge={FUNNEL_REFLECTION_TEXT_NUDGES.adviceToPeer} + descriptionAboveInput + /> + + ); + case 2: + return ( + <> +
+
+ +
+ + Not very + confident + +
+ onChange({ confidence: v })} + labelledBy={`ach-confidence-${id}`} + /> +
+ + Very + confident + +
+
+ onChange({ q7Text: v })} + nudge={FUNNEL_PARAGRAPH_TEXT_NUDGE} + /> +
+ +
+ + onChange({ q8Selections })} + columns={2} + /> +
+ +
+ + onChange({ q9Selections })} + /> +
+ + onChange({ oneSentence: v })} + fieldKey="oneSentence" + nudge={FUNNEL_REFLECTION_TEXT_NUDGES.oneSentence} + descriptionAboveInput + /> + + ); + default: + return null; + } +} + +export function AchievementForm({ + entry, + onChange, + showSaveErrors, + activeStep, + onActiveStepChange, + onFinish +}: AchievementFormProps) { + const stepConfig = FUNNEL_FORM_STEPS[activeStep]; + const isFirstStep = activeStep === 0; + const isLastStep = activeStep === FUNNEL_FORM_STEP_COUNT - 1; + const previousStep = FUNNEL_FORM_STEPS[activeStep - 1]; + const nextStep = FUNNEL_FORM_STEPS[activeStep + 1]; + + return ( +
+ {stepConfig ? ( +
+

+ {stepConfig.title} +

+
+ ) : null} + + {stepConfig ? ( +
{renderStepFields(activeStep, entry, onChange, showSaveErrors)}
+ ) : null} + +
+ {!isFirstStep && previousStep ? ( + + ) : null} + {!isLastStep && nextStep ? ( + + ) : onFinish ? ( + + ) : null} +
+
+ ); +} diff --git a/frontend/src/pages/student/digital-transcript/AchievementFormActions.tsx b/frontend/src/pages/student/digital-transcript/AchievementFormActions.tsx new file mode 100644 index 000000000..68d4d1850 --- /dev/null +++ b/frontend/src/pages/student/digital-transcript/AchievementFormActions.tsx @@ -0,0 +1,58 @@ +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import { + learningRecordOutlineButtonClassName, + learningRecordPrimaryButtonClassName, + LEARNING_RECORD_BUTTON_SIZE +} from './learningRecordButtons'; + +interface AchievementFormActionsProps { + onCancel: () => void; + onDone: () => void; + showDelete?: boolean; + onDeleteRequest?: () => void; +} + +export function AchievementFormActions({ + onCancel, + onDone, + showDelete, + onDeleteRequest +}: AchievementFormActionsProps) { + return ( +
+ + {showDelete && onDeleteRequest ? ( + + ) : null} + +
+ ); +} diff --git a/frontend/src/pages/student/digital-transcript/AchievementFormCategories.tsx b/frontend/src/pages/student/digital-transcript/AchievementFormCategories.tsx new file mode 100644 index 000000000..bfa0ea505 --- /dev/null +++ b/frontend/src/pages/student/digital-transcript/AchievementFormCategories.tsx @@ -0,0 +1,72 @@ +import type { TranscriptEntry } from '@/types/digital-transcript'; +import { AchievementFormActions } from './AchievementFormActions'; +import { AchievementFormCategoryCard } from './AchievementFormCategoryCard'; +import { AchievementFormMetadata } from './AchievementFormMetadata'; +import { ReflectionStepField } from './ReflectionStepField'; +import { REFLECTION_CATEGORIES } from './transcriptReflectionConfig'; + +interface AchievementFormCategoriesProps { + entry: TranscriptEntry; + onChange: (patch: Partial) => void; + onCancel: () => void; + onDone: () => void; + showDoneErrors: boolean; + showDelete?: boolean; + onDeleteRequest?: () => void; +} + +export function AchievementFormCategories({ + entry, + onChange, + onCancel, + onDone, + showDoneErrors, + showDelete, + onDeleteRequest +}: AchievementFormCategoriesProps) { + return ( +
+ + + + + {REFLECTION_CATEGORIES.map((section) => ( + +
+ {section.stepKeys.map((stepKey) => ( + + ))} +
+
+ ))} + + +
+ ); +} diff --git a/frontend/src/pages/student/digital-transcript/AchievementFormCategoryCard.tsx b/frontend/src/pages/student/digital-transcript/AchievementFormCategoryCard.tsx new file mode 100644 index 000000000..acd085928 --- /dev/null +++ b/frontend/src/pages/student/digital-transcript/AchievementFormCategoryCard.tsx @@ -0,0 +1,45 @@ +import type { ReactNode } from 'react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { cn } from '@/lib/utils'; + +interface AchievementFormCategoryCardProps { + title: string; + description?: string; + labelledBy?: string; + children: ReactNode; + className?: string; +} + +/** 15five-style section container for the learning-record-categories editor. */ +export function AchievementFormCategoryCard({ + title, + description, + labelledBy, + children, + className +}: AchievementFormCategoryCardProps) { + return ( + + + + {title} + + {description ? ( + + {description} + + ) : null} + + {children} + + ); +} diff --git a/frontend/src/pages/student/digital-transcript/AchievementFormMetadata.tsx b/frontend/src/pages/student/digital-transcript/AchievementFormMetadata.tsx new file mode 100644 index 000000000..df9de467c --- /dev/null +++ b/frontend/src/pages/student/digital-transcript/AchievementFormMetadata.tsx @@ -0,0 +1,104 @@ +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import type { TranscriptEntry } from '@/types/digital-transcript'; +import { cn } from '@/lib/utils'; +import { FUNNEL_FIELD_DESCRIPTIONS } from './transcriptReflectionConfig'; +import { learningRecordQuestionHeaderClassName } from './learningRecordButtons'; + +interface AchievementFormMetadataProps { + entry: TranscriptEntry; + onChange: (patch: Partial) => void; + showSaveErrors?: boolean; + /** Categories variant — alias for showSaveErrors. */ + showDoneErrors?: boolean; + /** When true, adds a bottom border to separate metadata from reflection sections. */ + showSectionDivider?: boolean; + /** Funnel variant — optional completion date, achievement labels. */ + variant?: 'default' | 'funnel'; +} + +export function AchievementFormMetadata({ + entry, + onChange, + showSaveErrors, + showDoneErrors, + showSectionDivider = false, + variant = 'default' +}: AchievementFormMetadataProps) { + const showErrors = showSaveErrors ?? showDoneErrors ?? false; + const isFunnel = variant === 'funnel'; + const programOk = Boolean(entry.programName.trim()); + const dateOk = Boolean(entry.completionDate.trim()); + const programLabel = isFunnel ? 'Achievement' : 'Program name'; + const dateLabel = isFunnel ? 'Program completion date' : 'Completion date'; + + return ( +
+
+
+ + {isFunnel ? ( +

+ {FUNNEL_FIELD_DESCRIPTIONS.programName} +

+ ) : null} +
+ onChange({ programName: e.target.value })} + aria-invalid={showErrors && !programOk} + className="h-10 border-border/80 bg-muted/40" + /> + {showErrors && !programOk ? ( +

+ {showSaveErrors + ? 'Please enter an achievement name to save your record.' + : 'Add a program or course name to continue.'} +

+ ) : null} +
+ +
+
+ + {isFunnel ? ( +

+ {FUNNEL_FIELD_DESCRIPTIONS.completionDate} +

+ ) : null} +
+ onChange({ completionDate: e.target.value })} + aria-invalid={!isFunnel && showErrors && !dateOk} + className="h-10 border-border/80 bg-muted/40" + /> + {!isFunnel && showErrors && !dateOk ? ( +

+ {showSaveErrors + ? 'Please add a completion date to save your achievement.' + : 'Add a completion date to continue.'} +

+ ) : null} +
+
+ ); +} diff --git a/frontend/src/pages/student/digital-transcript/AchievementFormSectionHeader.tsx b/frontend/src/pages/student/digital-transcript/AchievementFormSectionHeader.tsx new file mode 100644 index 000000000..7b8a4e713 --- /dev/null +++ b/frontend/src/pages/student/digital-transcript/AchievementFormSectionHeader.tsx @@ -0,0 +1,33 @@ +interface AchievementFormSectionHeaderProps { + sectionIndex: number; + sectionTotal: number; + title: string; + description: string; +} + +export function AchievementFormSectionHeader({ + sectionIndex, + sectionTotal, + title, + description +}: AchievementFormSectionHeaderProps) { + return ( +
+
+

{title}

+

+ {description} +

+
+

+ Section {sectionIndex} of {sectionTotal} +

+
+ ); +} diff --git a/frontend/src/pages/student/digital-transcript/AchievementPreview.tsx b/frontend/src/pages/student/digital-transcript/AchievementPreview.tsx new file mode 100644 index 000000000..d95bd9622 --- /dev/null +++ b/frontend/src/pages/student/digital-transcript/AchievementPreview.tsx @@ -0,0 +1,165 @@ +import { useLayoutEffect, useMemo, useRef, useState } from 'react'; +import type { TranscriptEntry } from '@/types/digital-transcript'; +import { countAnsweredReflections, reflectionSlotsTotal } from './learningRecordDocumentModel'; +import { LearningRecordDocument } from './LearningRecordDocument'; +import { buttonVariants } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +function entryFingerprint(e: TranscriptEntry): string { + return [ + e.id, + e.programName, + e.completionDate, + e.confidence, + e.topSkills.join(''), + e.whatMadeYouFinish, + e.goalConnection, + e.pride, + e.standoutMoment, + e.adviceToPeer, + e.oneSentence + ].join(''); +} + +/** US Letter height/width for portrait page */ +const LETTER_H_PER_W = 11 / 8.5; + +interface AchievementPreviewProps { + previewSource: TranscriptEntry | null; + /** Shown above the progress bar (e.g. "Currently editing achievement 3") */ + previewHeading: string | null; + emptyMessage?: string; +} + +export function AchievementPreview({ + previewSource, + previewHeading, + emptyMessage = 'Expand an achievement on the left to preview it here.' +}: AchievementPreviewProps) { + const pagesHostRef = useRef(null); + const previewContentRef = useRef(null); + const [pageHeightPx, setPageHeightPx] = useState(480); + const [contentHeightPx, setContentHeightPx] = useState(0); + + const answered = previewSource ? countAnsweredReflections(previewSource) : 0; + const totalSlots = reflectionSlotsTotal(); + const readinessPct = Math.round((answered / Math.max(1, totalSlots)) * 100); + + const measureKey = previewSource ? entryFingerprint(previewSource) : 'empty'; + + useLayoutEffect(() => { + function measure() { + const hostEl = pagesHostRef.current; + const contentEl = previewContentRef.current; + if (!hostEl) return; + const w = hostEl.clientWidth; + if (w < 48) return; + const letterH = w * LETTER_H_PER_W; + const maxH = hostEl.clientHeight; + const h = Math.max(220, Math.floor(Math.min(letterH, maxH))); + setPageHeightPx(h); + if (contentEl) { + setContentHeightPx(contentEl.offsetHeight); + } + } + + measure(); + const ro = new ResizeObserver(() => { + measure(); + const c = previewContentRef.current; + if (c) setContentHeightPx(c.offsetHeight); + }); + const hostEl = pagesHostRef.current; + const contentEl = previewContentRef.current; + if (hostEl) ro.observe(hostEl); + if (contentEl) ro.observe(contentEl); + return () => ro.disconnect(); + }, [measureKey]); + + const totalPages = useMemo( + () => Math.max(1, Math.ceil(contentHeightPx / Math.max(1, pageHeightPx))), + [contentHeightPx, pageHeightPx] + ); + + if (!previewSource || !previewHeading) { + return ( +
+
+

{emptyMessage}

+
+
+ ); + } + + return ( +
+

{previewHeading}

+
+
+ Reflections answered + + {answered} of {totalSlots} + +
+
+
+
+
+ +
+
+
+ +
+ {totalPages > 1 + ? Array.from({ length: totalPages - 1 }, (_, i) => ( +
+ )) + : null} +
+ +
+ {totalPages} / {totalPages} +
+
+
+ ); +} diff --git a/frontend/src/pages/student/digital-transcript/AchievementRow.tsx b/frontend/src/pages/student/digital-transcript/AchievementRow.tsx new file mode 100644 index 000000000..91d3067d2 --- /dev/null +++ b/frontend/src/pages/student/digital-transcript/AchievementRow.tsx @@ -0,0 +1,157 @@ +import { ChevronDown, ChevronUp } from 'lucide-react'; +import { Collapsible, CollapsibleContent } from '@/components/ui/collapsible'; +import { cn } from '@/lib/utils'; +import type { TranscriptEntry } from '@/types/digital-transcript'; +import { + countEditorFormSlots, + editorFormSlotsTotal, + entryIsComplete +} from './learningRecordDocumentModel'; +import { getEntryDisplayTitle } from './entryTitleDisplay'; +import type { LearningRecordFormVariant } from './learningRecordPrototypes'; +import { AchievementForm } from './AchievementForm'; +import { AchievementFormCategories } from './AchievementFormCategories'; + +function formatCompletedShort(iso: string): string { + if (!iso.trim()) return ''; + return new Date(`${iso}T12:00:00`).toLocaleDateString(undefined, { + year: 'numeric', + month: 'long', + day: 'numeric' + }); +} + +function collapsedDateLine(entry: TranscriptEntry): string { + const datePart = formatCompletedShort(entry.completionDate); + return datePart ? `Completed ${datePart}` : 'Completion date not set'; +} + +interface AchievementRowProps { + formVariant: LearningRecordFormVariant; + entry: TranscriptEntry; + isExpanded: boolean; + onToggleExpand: () => void; + onPatch: (patch: Partial) => void; + onCancel?: () => void; + onDone?: () => void; + showDoneErrors?: boolean; + showSaveErrors?: boolean; + showDelete?: boolean; + onDeleteRequest?: () => void; + activeStep?: number; + onActiveStepChange?: (step: number) => void; + /** Funnel: validate metadata then navigate home (Finish button). */ + onFinish?: () => void; +} + +export function AchievementRow({ + formVariant, + entry, + isExpanded, + onToggleExpand, + onPatch, + onCancel, + onDone, + showDoneErrors = false, + showSaveErrors = false, + showDelete, + onDeleteRequest, + activeStep = 0, + onActiveStepChange, + onFinish +}: AchievementRowProps) { + if (formVariant === 'funnel') { + return ( +
+ {})} + onFinish={onFinish} + /> +
+ ); + } + + const title = getEntryDisplayTitle(entry.programName); + const filled = countEditorFormSlots(entry); + const total = editorFormSlotsTotal(); + const complete = entryIsComplete(entry, 'categories'); + const progressPct = Math.round((filled / total) * 100); + + return ( + + + +
+ {})} + onDone={onDone ?? (() => {})} + showDoneErrors={showDoneErrors} + showDelete={showDelete} + onDeleteRequest={onDeleteRequest} + /> +
+
+
+ ); +} diff --git a/frontend/src/pages/student/digital-transcript/AchievementsRecordPreview.tsx b/frontend/src/pages/student/digital-transcript/AchievementsRecordPreview.tsx new file mode 100644 index 000000000..0891a69cd --- /dev/null +++ b/frontend/src/pages/student/digital-transcript/AchievementsRecordPreview.tsx @@ -0,0 +1,112 @@ +import { useLayoutEffect, useMemo, useRef } from 'react'; +import { Download, Loader2 } from 'lucide-react'; +import { useAuth } from '@/auth/useAuth'; +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import type { TranscriptEntry } from '@/types/digital-transcript'; +import { sortEntriesNewestFirst } from './transcriptEntrySessionStorage'; +import { LearningRecordExportContent } from './LearningRecordExportContent'; +import { learningRecordOutlineButtonClassName, LEARNING_RECORD_BUTTON_SIZE } from './learningRecordButtons'; +import { learningRecordResidentDisplayName } from './learningRecordResidentName'; + +export interface FunnelDownloadProps { + onDownload: () => void; + canDownload: boolean; + isExporting: boolean; +} + +interface AchievementsRecordPreviewProps { + rows: TranscriptEntry[]; + anchorId: string | null; + variant?: 'default' | 'funnel'; + funnelDownload?: FunnelDownloadProps; +} + +export function AchievementsRecordPreview({ + rows, + anchorId, + variant = 'default', + funnelDownload +}: AchievementsRecordPreviewProps) { + const { user } = useAuth(); + const residentName = learningRecordResidentDisplayName(user); + const docRows = useMemo(() => sortEntriesNewestFirst(rows), [rows]); + const scrollRef = useRef(null); + + useLayoutEffect(() => { + if (!anchorId || !scrollRef.current) return; + const block = scrollRef.current.querySelector( + `[data-achievement-id="${anchorId}"]` + ); + block?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + }, [anchorId, docRows.length]); + + const isFunnel = variant === 'funnel'; + + const previewContent = ( + + ); + + if (isFunnel) { + return ( + + {funnelDownload ? ( +
+ +
+ ) : null} +
+ {previewContent} +
+
+ ); + } + + return ( +
+
+ {previewContent} +
+
+ ); +} diff --git a/frontend/src/pages/student/digital-transcript/AddAchievementRow.tsx b/frontend/src/pages/student/digital-transcript/AddAchievementRow.tsx new file mode 100644 index 000000000..ffdf0441f --- /dev/null +++ b/frontend/src/pages/student/digital-transcript/AddAchievementRow.tsx @@ -0,0 +1,32 @@ +import { ChevronDown, Plus } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import { LEARNING_RECORD_BUTTON_SIZE } from './learningRecordButtons'; + +interface AddAchievementRowProps { + onAdd: () => void; +} + +export function AddAchievementRow({ onAdd }: AddAchievementRowProps) { + return ( + + ); +} diff --git a/frontend/src/pages/student/digital-transcript/ConfidenceSegmentedControl.tsx b/frontend/src/pages/student/digital-transcript/ConfidenceSegmentedControl.tsx new file mode 100644 index 000000000..19957c145 --- /dev/null +++ b/frontend/src/pages/student/digital-transcript/ConfidenceSegmentedControl.tsx @@ -0,0 +1,129 @@ +import { useCallback, type KeyboardEvent } from 'react'; +import { cn } from '@/lib/utils'; +import { CONFIDENCE_LEVEL_SOLID } from './confidenceLevelVisual'; +import { CONFIDENCE_RADIO_OPTIONS } from './transcriptReflectionConfig'; + +const LEVEL_VISUAL = CONFIDENCE_LEVEL_SOLID.map((solid) => ({ solid })); + +const NUMBER_DEFAULT = 'text-black dark:text-gray-100'; +const NUMBER_SELECTED = 'text-black dark:text-gray-50'; +const NUMBER_DIMMED = 'text-muted-foreground/40 dark:text-muted-foreground/50'; + +interface ConfidenceSegmentedControlProps { + value: string; + onChange?: (next: string) => void; + /** Matches section `aria-labelledby` for the question text. */ + labelledBy: string; + /** Preview/PDF: display-only, no interaction. */ + readOnly?: boolean; +} + +export function ConfidenceSegmentedControl({ + value, + onChange, + labelledBy, + readOnly = false +}: ConfidenceSegmentedControlProps) { + const selectedNum = /^[1-5]$/.test(value) ? Number(value) : 0; + const palette = selectedNum > 0 ? LEVEL_VISUAL[selectedNum - 1] : null; + + const move = useCallback( + (delta: number) => { + if (readOnly || !onChange) return; + let next: number; + if (!selectedNum) { + next = delta > 0 ? 1 : 5; + } else { + next = Math.min(5, Math.max(1, selectedNum + delta)); + } + onChange(String(next)); + }, + [onChange, readOnly, selectedNum] + ); + + function onKeyDown(e: KeyboardEvent) { + if (readOnly) return; + if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { + e.preventDefault(); + move(1); + } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { + e.preventDefault(); + move(-1); + } else if (e.key >= '1' && e.key <= '5') { + e.preventDefault(); + onChange?.(e.key); + } + } + + return ( +
+
+ {CONFIDENCE_RADIO_OPTIONS.map(([val], index) => { + const n = index + 1; + const isSelected = value === val; + const bgClass = + isSelected && palette !== null ? palette.solid : 'bg-transparent'; + const numberClass = + palette === null + ? NUMBER_DEFAULT + : isSelected + ? NUMBER_SELECTED + : NUMBER_DIMMED; + + const segmentProps = readOnly + ? { role: 'presentation' as const } + : { + role: 'radio' as const, + 'aria-checked': isSelected, + tabIndex: isSelected ? 0 : !value && n === 1 ? 0 : -1, + onClick: () => onChange?.(val) + }; + + const SegmentTag = readOnly ? 'div' : 'button'; + + return ( + + + {val} + + + {CONFIDENCE_RADIO_OPTIONS[index][1]} + {isSelected ? ', selected' : ''} + + + ); + })} +
+
+ ); +} diff --git a/frontend/src/pages/student/digital-transcript/DigitalTranscriptEntryPage.tsx b/frontend/src/pages/student/digital-transcript/DigitalTranscriptEntryPage.tsx new file mode 100644 index 000000000..18ee026e8 --- /dev/null +++ b/frontend/src/pages/student/digital-transcript/DigitalTranscriptEntryPage.tsx @@ -0,0 +1,276 @@ +import { useCallback, useRef, useState } from 'react'; +import { createPortal, flushSync } from 'react-dom'; +import { Download, Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { useAuth } from '@/auth/useAuth'; +import { Button } from '@/components/ui/button'; +import { useTranscriptDraft } from '@/hooks/useTranscriptDraft'; +import { cn } from '@/lib/utils'; +import { + downloadLearningRecordPdf, + learningRecordPdfFilename +} from '@/utils/downloadLearningRecordPdf'; +import { getDigitalTranscriptBasePath, setDigitalTranscriptStorageContext } from './digitalTranscriptRoutes'; +import { getLearningRecordFormVariant } from './learningRecordPrototypes'; +import { + DigitalTranscriptWysiwygEntry, + type AutoSaveStatus, + type FunnelAutoSaveState, + type FunnelFinishHandlers +} from './DigitalTranscriptWysiwygEntry'; +import { DigitalTranscriptBackLink, DigitalTranscriptShell, dtPageSurface } from './DigitalTranscriptShell'; +import { LearningRecordExportContent } from './LearningRecordExportContent'; +import { learningRecordOutlineButtonClassName, LEARNING_RECORD_BUTTON_SIZE } from './learningRecordButtons'; +import { learningRecordResidentDisplayName } from './learningRecordResidentName'; +import type { TranscriptEntry } from '@/types/digital-transcript'; + +function formatSavedTime(date: Date): string { + return date.toLocaleTimeString(undefined, { + hour: 'numeric', + minute: '2-digit', + hour12: true + }); +} + +function AutoSaveLabel({ + status, + lastSavedAt +}: { + status: AutoSaveStatus; + lastSavedAt: Date | null; +}) { + if (status === 'pending' || status === 'saving') { + return ( + + Saving… + + ); + } + + if (status === 'error') { + return ( + + Failed to save + + ); + } + + if (status === 'saved' && lastSavedAt) { + return ( + + Saved {formatSavedTime(lastSavedAt)} + + ); + } + + return null; +} + +export default function DigitalTranscriptEntryPage() { + const navigate = useNavigate(); + const { pathname } = useLocation(); + const base = getDigitalTranscriptBasePath(pathname); + setDigitalTranscriptStorageContext(base); + const formVariant = getLearningRecordFormVariant(pathname); + const isFunnel = formVariant === 'funnel'; + const funnelFinishRef = useRef(null); + const { hydrated, upsertCommittedEntry, deleteCommittedEntry, entries } = useTranscriptDraft(); + const { user } = useAuth(); + const residentName = learningRecordResidentDisplayName(user); + const [exportRows, setExportRows] = useState([]); + const [isExporting, setIsExporting] = useState(false); + const [exportActive, setExportActive] = useState(false); + const [autoSaveStatus, setAutoSaveStatus] = useState('idle'); + const [lastSavedAt, setLastSavedAt] = useState(null); + const exportRootRef = useRef(null); + const liveExportRowsRef = useRef(exportRows); + liveExportRowsRef.current = exportRows; + + const canDownload = exportRows.length > 0 && !isExporting; + + const handleExportRowsChange = useCallback((rows: TranscriptEntry[]) => { + setExportRows(rows); + }, []); + + const handleRegisterFunnelFinish = useCallback((handlers: FunnelFinishHandlers) => { + funnelFinishRef.current = handlers; + }, []); + + const handleFunnelAutoSaveStatusChange = useCallback((state: FunnelAutoSaveState) => { + setAutoSaveStatus(state.status); + setLastSavedAt(state.lastSavedAt); + }, []); + + const navigateHome = useCallback(() => { + navigate(base); + }, [navigate, base]); + + const handleFinish = useCallback(() => { + const ok = funnelFinishRef.current?.validateFinishRequirements() ?? false; + if (ok) navigateHome(); + }, [navigateHome]); + + const handleDownload = useCallback(async () => { + const rows = + liveExportRowsRef.current.length > 0 + ? liveExportRowsRef.current + : readLearningRecordExportRows(); + if (rows.length === 0 || isExporting) return; + + setIsExporting(true); + flushSync(() => { + setExportActive(true); + setExportRows(rows); + }); + + try { + await new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }); + + const root = exportRootRef.current; + if (!root) { + throw new Error('Export content not ready'); + } + + await downloadLearningRecordPdf( + root, + learningRecordPdfFilename(residentName) + ); + toast.success('Learning record downloaded'); + } catch (err) { + console.error('Learning record PDF export failed:', err); + toast.error('Could not download PDF. Please try again.'); + } finally { + setExportActive(false); + setIsExporting(false); + } + }, [isExporting, residentName]); + + if (!hydrated) { + return ( + +
+ +

Loading your editor…

+
+
+ ); + } + + return ( +
+ {exportActive && + createPortal( +
+ +
, + document.body + )} +
+
+ {isFunnel ? ( + + ) : ( + Back + )} +
+ {isFunnel ? ( + + ) : null} + {!isFunnel ? ( + + ) : null} +
+
+
+ void handleDownload(), + canDownload, + isExporting + } + : undefined + } + /> +
+
+
+ ); +} diff --git a/frontend/src/pages/student/digital-transcript/DigitalTranscriptHome.tsx b/frontend/src/pages/student/digital-transcript/DigitalTranscriptHome.tsx new file mode 100644 index 000000000..3a370b0c3 --- /dev/null +++ b/frontend/src/pages/student/digital-transcript/DigitalTranscriptHome.tsx @@ -0,0 +1,817 @@ +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; +import { createPortal, flushSync } from 'react-dom'; +import { Link, useLocation, useNavigate } from 'react-router-dom'; +import { ChevronDown, ChevronsUpDown, ChevronUp, Download, Eye, Loader2, Plus, Trash2, X } from 'lucide-react'; +import { toast } from 'sonner'; +import { useAuth } from '@/auth/useAuth'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog'; +import { Separator } from '@/components/ui/separator'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from '@/components/ui/table'; +import { EmptyState, PageHeader } from '@/components/shared'; +import { useTranscriptDraft } from '@/hooks/useTranscriptDraft'; +import { cn } from '@/lib/utils'; +import { + downloadLearningRecordPdf, + learningRecordPdfFilename +} from '@/utils/downloadLearningRecordPdf'; +import type { TranscriptEntry } from '@/types/digital-transcript'; +import { + countAnsweredReflections, + reflectionSlotsTotal +} from '@/pages/student/digital-transcript/learningRecordDocumentModel'; +import { CONFIDENCE_LEVEL_SOLID } from './confidenceLevelVisual'; +import { getDigitalTranscriptBasePath, setDigitalTranscriptStorageContext } from './digitalTranscriptRoutes'; +import { + getLearningRecordFormVariant, + type LearningRecordFormVariant +} from './learningRecordPrototypes'; +import { DigitalTranscriptEyebrow, DigitalTranscriptShell } from './DigitalTranscriptShell'; +import { LearningRecordExportContent } from './LearningRecordExportContent'; +import { learningRecordResidentDisplayName } from './learningRecordResidentName'; +import { TranscriptResumePreview } from './TranscriptResumePreview'; +import { ViewAllAchievementsSheet } from './ViewAllAchievementsSheet'; +import { PrintShareHelpLink } from '@/components/learning-record/PrintShareHelpLink'; +import { + countFunnelFieldsAnswered, + funnelCompletionTier, + FUNNEL_FORM_FIELD_TOTAL +} from './transcriptReflectionConfig'; +import { getEntryDisplayTitle } from './entryTitleDisplay'; +import { + readTableSortFromSession, + sortTranscriptEntries, + toggleTableSort, + writeTableSortToSession, + type SortColumn, + type TableSort +} from './learningRecordTableSort'; +import { + LEARNING_RECORD_BUTTON_SIZE, + learningRecordOutlineButtonClassName, + learningRecordPrimaryButtonClassName +} from './learningRecordButtons'; + +/** Decorative sample for the home CTA thumbnail (not persisted). */ +const ACHIEVEMENT_LOG_THUMBNAIL_SAMPLE: TranscriptEntry = { + id: '__home_thumb_sample__', + createdAt: '', + programName: 'Your next achievement', + completionDate: '2025-06-01', + topSkills: ['Study habits', 'Test strategies', 'Time management'], + whatMadeYouFinish: 'Checking off each milestone kept me going.', + confidence: '4', + pride: 'Sticking with it when the material felt impossible at first.', + goalConnection: 'A clear step toward licensing and steadier work.', + standoutMoment: 'Instructors who explained things with patience and respect.', + adviceToPeer: 'Use the tutor hours—you are not alone in the room.', + oneSentence: 'A program that helped me believe I could finish what I started.', + q4Toggle: null, + q4Text: '', + q5BeforeTags: [], + q5AfterTags: [], + q5FreeText: '', + q7Text: '', + q8Selections: [], + q9Selections: [] +}; + +const FUNNEL_SUBTITLE = + "This is your personal record of the programs you've finished and the skills you've built. Everything you add is saved here. When you're ready, you can save your record as a PDF to print, share, or take with you."; + +const EMPTY_FIELD_LABEL = 'Not added yet'; + +const primaryCtaClassName = cn(learningRecordPrimaryButtonClassName, 'sm:min-w-[11rem]'); + +function formatProgramCompletedDate(entry: TranscriptEntry): string { + if (!entry.completionDate.trim()) return EMPTY_FIELD_LABEL; + return new Date(entry.completionDate + 'T12:00:00').toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric' + }); +} + +function formatSavedOn(iso: string): string { + if (!iso.trim()) return '—'; + return new Date(iso).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric' + }); +} + +function savedHereLabel(count: number): string { + const word = count === 1 ? 'achievement' : 'achievements'; + return `You have ${count} ${word} saved here.`; +} + +function getEntryQuestionsProgress( + entry: TranscriptEntry, + formVariant: LearningRecordFormVariant +): { answered: number; total: number } { + if (formVariant === 'funnel') { + return { + answered: countFunnelFieldsAnswered(entry), + total: FUNNEL_FORM_FIELD_TOTAL + }; + } + return { + answered: countAnsweredReflections(entry), + total: reflectionSlotsTotal() + }; +} + +function QuestionsAnsweredBadge({ + answered, + total +}: { + answered: number; + total: number; +}) { + const tier = funnelCompletionTier(answered, total); + const bg = CONFIDENCE_LEVEL_SOLID[tier - 1]; + return ( + + {answered} / {total} + + ); +} + +function SortableColumnHeader({ + label, + column, + tableSort, + onSortColumn, + className +}: { + label: string; + column: SortColumn; + tableSort: TableSort; + onSortColumn: (column: SortColumn) => void; + className?: string; +}) { + const isActive = tableSort.column === column; + const SortIcon = !isActive + ? ChevronsUpDown + : tableSort.direction === 'asc' + ? ChevronUp + : ChevronDown; + + return ( + + + + ); +} + +interface SavedEntriesSectionProps { + entries: TranscriptEntry[]; + sortedEntries: TranscriptEntry[]; + entryPath: string; + formVariant: LearningRecordFormVariant; + sectionHeading: ReactNode; + headerAction?: ReactNode; + emptyState: { title: string; description: string }; + tableSort: TableSort; + onSortColumn: (column: SortColumn) => void; + onDeleteRequest: (entry: TranscriptEntry) => void; + onDownloadEntry: (entry: TranscriptEntry) => void; + downloadingEntryId: string | null; + isDownloadBusy: boolean; +} + +function SavedEntriesSection({ + entries, + sortedEntries, + entryPath, + formVariant, + sectionHeading, + headerAction, + emptyState, + tableSort, + onSortColumn, + onDeleteRequest, + onDownloadEntry, + downloadingEntryId, + isDownloadBusy +}: SavedEntriesSectionProps) { + return ( +
+
+
{sectionHeading}
+ {headerAction} +
+ + + + {entries.length === 0 ? ( + + ) : ( + + + + + + + + + + + Actions + + + + + {sortedEntries.map((entry) => { + const editHref = `${entryPath}?edit=${encodeURIComponent(entry.id)}`; + const { answered, total } = getEntryQuestionsProgress( + entry, + formVariant + ); + const isDownloading = downloadingEntryId === entry.id; + + return ( + + + +
{ + e.stopPropagation(); + e.preventDefault(); + }} + > + + +
+
+
+ ); + })} +
+
+
+
+ )} +
+ ); +} + +export default function DigitalTranscriptHome() { + const navigate = useNavigate(); + const { pathname } = useLocation(); + const base = getDigitalTranscriptBasePath(pathname); + setDigitalTranscriptStorageContext(base); + const formVariant = getLearningRecordFormVariant(pathname); + const isFunnel = formVariant === 'funnel'; + const entryPath = `${base}/entry`; + const { entries, hydrated, hasDraft, deleteCommittedEntry } = + useTranscriptDraft(); + const { user } = useAuth(); + const residentName = learningRecordResidentDisplayName(user); + const [deleteTarget, setDeleteTarget] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + const [exportRows, setExportRows] = useState([]); + const [exportActive, setExportActive] = useState(false); + const [downloadingEntryId, setDownloadingEntryId] = useState(null); + const [viewAllOpen, setViewAllOpen] = useState(false); + const [tableSort, setTableSort] = useState(readTableSortFromSession); + const exportRootRef = useRef(null); + + useEffect(() => { + writeTableSortToSession(tableSort); + }, [tableSort]); + + const sortedEntries = useMemo( + () => sortTranscriptEntries(entries, tableSort, formVariant), + [entries, tableSort, formVariant] + ); + + const isDownloadBusy = downloadingEntryId !== null; + + const handleSortColumn = useCallback((column: SortColumn) => { + setTableSort((current) => toggleTableSort(current, column)); + }, []); + + const waitForExportPaint = useCallback(async () => { + await new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }); + }, []); + + const handleDownloadEntry = useCallback( + async (entry: TranscriptEntry) => { + if (isDownloadBusy) return; + + setDownloadingEntryId(entry.id); + flushSync(() => { + setExportActive(true); + setExportRows([entry]); + }); + + try { + await waitForExportPaint(); + + const root = exportRootRef.current; + if (!root) { + throw new Error('Export content not ready'); + } + + await downloadLearningRecordPdf( + root, + learningRecordPdfFilename(residentName) + ); + toast.success('Your record was saved as a PDF'); + } catch (err) { + console.error('Learning record PDF export failed:', err); + toast.error('Could not save PDF. Please try again.'); + } finally { + setExportActive(false); + setDownloadingEntryId(null); + } + }, + [isDownloadBusy, residentName, waitForExportPaint] + ); + + const handleConfirmDelete = useCallback(() => { + if (!deleteTarget || isDeleting) return; + + setIsDeleting(true); + void deleteCommittedEntry(deleteTarget.id).finally(() => { + setIsDeleting(false); + setDeleteTarget(null); + }); + }, [deleteCommittedEntry, deleteTarget, isDeleting]); + + const handleStartNewClick = useCallback(() => { + setViewAllOpen(false); + navigate(`${entryPath}?intent=new`); + }, [entryPath, navigate]); + + if (!hydrated) { + return ( + +
+ +

Loading your record…

+
+
+ ); + } + + const viewAllAchievementsButton = + entries.length > 0 ? ( + + ) : null; + + const primaryCtaLabel = entries.length === 0 ? 'Start logging' : 'Add achievement'; + + const cardTitle = + entries.length === 0 && !hasDraft ? 'Start logging' : 'Build your Achievements Record'; + + const deleteProgramName = getEntryDisplayTitle(deleteTarget?.programName, 'Untitled'); + + return ( + + {exportActive && + createPortal( +
+ +
, + document.body + )} + {isFunnel ? ( + <> + + + + + {savedHereLabel(entries.length)} + + } + headerAction={ +
+ {viewAllAchievementsButton} + +
+ } + tableSort={tableSort} + onSortColumn={handleSortColumn} + emptyState={{ + title: 'No achievements added yet', + description: + "Click 'Add achievement' to document your first program, skill, or learning. Your record builds from here." + }} + onDeleteRequest={setDeleteTarget} + onDownloadEntry={(entry) => void handleDownloadEntry(entry)} + downloadingEntryId={downloadingEntryId} + isDownloadBusy={isDownloadBusy} + /> + + ) : ( + <> + + + + +
+
+ + + {cardTitle} + + + {hasDraft ? ( + <> + You have work in progress in the editor. Continue where you left + off, or add a new program achievement. + + ) : entries.length === 0 ? ( + <> + Open the editor and fill in what you did. Nothing appears on + this list until you tap Done. + + ) : ( + <> + You have saved {entries.length}{' '} + {entries.length === 1 ? 'achievement' : 'achievements'}. Add + another anytime. + + )} + + + + {hasDraft ? ( + <> + + + + ) : ( + <> + {entries.length > 0 && ( + <> + + + + )} + {entries.length === 0 && ( + + )} + + )} + +
+
+
+
+
+ +
+
+
+
+
+
+ + + Saved entries +

+ Saved here +

+ + } + headerAction={ + entries.length > 0 ? ( +
+ {viewAllAchievementsButton} +

+ {entries.length}{' '} + {entries.length === 1 ? 'achievement' : 'achievements'} +

+
+ ) : undefined + } + tableSort={tableSort} + onSortColumn={handleSortColumn} + emptyState={{ + title: 'Nothing saved yet', + description: + 'When you finish editing and tap Done, your achievement appears here.' + }} + onDeleteRequest={setDeleteTarget} + onDownloadEntry={(entry) => void handleDownloadEntry(entry)} + downloadingEntryId={downloadingEntryId} + isDownloadBusy={isDownloadBusy} + /> + + )} + + { + if (!open && !isDeleting) setDeleteTarget(null); + }} + > + + + Delete this achievement? + + This will permanently remove {deleteProgramName} from your + learning record. This cannot be undone. + + + + + + + + +
+ ); +} diff --git a/frontend/src/pages/student/digital-transcript/DigitalTranscriptShell.tsx b/frontend/src/pages/student/digital-transcript/DigitalTranscriptShell.tsx new file mode 100644 index 000000000..950d84545 --- /dev/null +++ b/frontend/src/pages/student/digital-transcript/DigitalTranscriptShell.tsx @@ -0,0 +1,89 @@ +import type { ReactNode } from 'react'; +import { Link } from 'react-router-dom'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import { LEARNING_RECORD_BUTTON_SIZE } from './learningRecordButtons'; + +/** Page canvas — shadcn `muted` surface */ +export const dtPageSurface = 'bg-muted'; + +/** Viewport below TopNav (`h-16`) — fills visible main area on Learning Record routes */ +export const dtShellMinHeight = 'min-h-[calc(100dvh-4rem)]'; + +interface ShellProps { + children: ReactNode; + /** + * `narrow` — centered column for lightweight states (e.g. loading). + * `wide` — full width within the same `max-w-7xl` content area as Knowledge Center / Home. + */ + variant?: 'narrow' | 'wide'; +} + +/** + * Learning Record shell: page scrolls with the main layout (no nested ScrollArea). + * `max-w-7xl mx-auto px-6 py-8` matches ResidentKnowledgeCenter rhythm. + */ +export function DigitalTranscriptShell({ children, variant = 'wide' }: ShellProps) { + const innerClass = variant === 'narrow' ? 'mx-auto w-full max-w-xl' : 'w-full'; + + return ( +
+
+
{children}
+
+
+ ); +} + +export function DigitalTranscriptBackLink({ + to, + children +}: { + to: string; + children: ReactNode; +}) { + return ( + + ); +} + +export function DigitalTranscriptEyebrow({ children }: { children: ReactNode }) { + return ( +

+ {children} +

+ ); +} + +export function DigitalTranscriptPageTitle({ children }: { children: ReactNode }) { + return ( +

{children}

+ ); +} diff --git a/frontend/src/pages/student/digital-transcript/DigitalTranscriptWysiwygEntry.tsx b/frontend/src/pages/student/digital-transcript/DigitalTranscriptWysiwygEntry.tsx new file mode 100644 index 000000000..416b5eb1b --- /dev/null +++ b/frontend/src/pages/student/digital-transcript/DigitalTranscriptWysiwygEntry.tsx @@ -0,0 +1,760 @@ +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { Plus } from 'lucide-react'; +import { useSearchParams } from 'react-router-dom'; +import { ConfirmDialog } from '@/components/shared'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { cn } from '@/lib/utils'; +import type { TranscriptEntry, TranscriptEntrySession } from '@/types/digital-transcript'; +import { + cloneTranscriptEntry, + createEmptyTranscriptEntry, + dispatchEntrySessionUpdated, + entryHasExportableContent, + entryPayloadEqual, + filterEntriesForExport, + resolveInitialEntrySession, + sortEntriesNewestFirst, + syncSessionRowsAfterUpsert, + writeEntrySessionToStorage +} from '@/pages/student/digital-transcript/transcriptEntrySessionStorage'; +import { getEntryDisplayTitle } from '@/pages/student/digital-transcript/entryTitleDisplay'; +import { + AchievementsRecordPreview, + type FunnelDownloadProps +} from './AchievementsRecordPreview'; +import { AchievementRow } from './AchievementRow'; +import type { LearningRecordFormVariant } from './learningRecordPrototypes'; +import { + entryIsComplete, + firstIncompleteFunnelStep +} from './learningRecordDocumentModel'; +import { CONFIDENCE_LEVEL_SOLID } from './confidenceLevelVisual'; +import { LEARNING_RECORD_BUTTON_SIZE } from './learningRecordButtons'; +import { + countFunnelFieldsAnswered, + countFunnelStepFieldsAnswered, + countFunnelStepFieldsTotal, + funnelCompletionTier, + FUNNEL_FORM_FIELD_TOTAL, + FUNNEL_FORM_STEPS, + TOP_SKILLS_MAX +} from './transcriptReflectionConfig'; + +/** Newest uncommitted row with no answers yet — safe to reopen instead of duplicating. */ +function findReusableBlankDraftRow( + rows: TranscriptEntry[], + committedIds: Set +): TranscriptEntry | null { + for (const row of sortEntriesNewestFirst(rows)) { + if (committedIds.has(row.id)) continue; + if (!entryHasExportableContent(row)) return row; + } + return null; +} + +function ensureDraftEditorOpen( + session: TranscriptEntrySession, + committed: TranscriptEntry[] +): TranscriptEntrySession { + const committedIds = new Set(committed.map((e) => e.id)); + const reusable = findReusableBlankDraftRow(session.rows, committedIds); + if (reusable) { + return { + ...session, + expandedId: reusable.id, + lastPreviewId: reusable.id + }; + } + const row = createEmptyTranscriptEntry(); + return { + ...session, + rows: [row, ...session.rows], + expandedId: row.id, + lastPreviewId: row.id + }; +} + +/** Funnel editor: one achievement row per visit, form expanded. */ +function toFunnelSingleRowSession( + session: TranscriptEntrySession, + committed: TranscriptEntry[], + options: { intent?: boolean; edit?: string | null } +): TranscriptEntrySession { + if (options.intent) { + const opened = ensureDraftEditorOpen(session, committed); + const rowId = opened.expandedId ?? opened.rows[0]?.id; + const row = + opened.rows.find((r) => r.id === rowId) ?? + findReusableBlankDraftRow(opened.rows, new Set(committed.map((e) => e.id))) ?? + createEmptyTranscriptEntry(); + const cloned = cloneTranscriptEntry(row); + return { + ...opened, + rows: [cloned], + expandedId: cloned.id, + lastPreviewId: cloned.id + }; + } + + if (options.edit) { + const fromSession = session.rows.find((r) => r.id === options.edit); + const fromCommitted = committed.find((e) => e.id === options.edit); + const row = fromSession ?? fromCommitted; + if (row) { + const cloned = cloneTranscriptEntry(row); + return { + ...session, + rows: [cloned], + expandedId: cloned.id, + lastPreviewId: cloned.id + }; + } + } + + if (session.rows.length === 0) { + const opened = ensureDraftEditorOpen(session, committed); + const row = + opened.rows.find((r) => r.id === opened.expandedId) ?? + opened.rows[0] ?? + createEmptyTranscriptEntry(); + const cloned = cloneTranscriptEntry(row); + return { + ...opened, + rows: [cloned], + expandedId: cloned.id, + lastPreviewId: cloned.id + }; + } + + const preferredId = + session.expandedId ?? sortEntriesNewestFirst(session.rows)[0]?.id ?? null; + const row = + session.rows.find((r) => r.id === preferredId) ?? session.rows[0]; + const cloned = cloneTranscriptEntry(row); + return { + ...session, + rows: [cloned], + expandedId: cloned.id, + lastPreviewId: cloned.id + }; +} + +export type AutoSaveStatus = 'idle' | 'pending' | 'saving' | 'saved' | 'error'; + +export interface FunnelAutoSaveState { + status: AutoSaveStatus; + lastSavedAt: Date | null; +} + +export interface FunnelFinishHandlers { + validateFinishRequirements: () => boolean; +} + +const COMMITTED_AUTOSAVE_MS = 500; + +interface DigitalTranscriptWysiwygEntryProps { + formVariant: LearningRecordFormVariant; + hydrated: boolean; + entries: TranscriptEntry[]; + upsertCommittedEntry: (entry: TranscriptEntry) => void; + deleteCommittedEntry: (id: string) => TranscriptEntrySession | null; + /** Live session rows for PDF export (includes in-progress autosaved work). */ + onExportRowsChange?: (rows: TranscriptEntry[]) => void; + /** Funnel: validate metadata then navigate home (Finish button). */ + funnelOnFinish?: () => void; + /** Funnel: register Finish validation for the entry page. */ + onRegisterFunnelFinish?: (handlers: FunnelFinishHandlers) => void; + /** Funnel: report debounced auto-save status for the toolbar label. */ + onFunnelAutoSaveStatusChange?: (state: FunnelAutoSaveState) => void; + /** Funnel: PDF download wired from the entry page (rendered in the preview pane). */ + funnelDownload?: FunnelDownloadProps; +} + +export type { FunnelDownloadProps }; + +export function DigitalTranscriptWysiwygEntry({ + formVariant, + hydrated, + entries, + upsertCommittedEntry, + deleteCommittedEntry, + onExportRowsChange, + funnelOnFinish, + onRegisterFunnelFinish, + onFunnelAutoSaveStatusChange, + funnelDownload +}: DigitalTranscriptWysiwygEntryProps) { + const isFunnel = formVariant === 'funnel'; + const [searchParams, setSearchParams] = useSearchParams(); + const [session, setSession] = useState(null); + const [saveErrorRowId, setSaveErrorRowId] = useState(null); + const [activeStep, setActiveStep] = useState(0); + const [deleteConfirmFor, setDeleteConfirmFor] = useState(null); + const baselinesRef = useRef>({}); + const prevExpandedIdRef = useRef(null); + const achievementListRef = useRef(null); + const sessionRef = useRef(null); + sessionRef.current = session; + + const committedIds = useMemo(() => new Set(entries.map((e) => e.id)), [entries]); + + const captureBaseline = useCallback((id: string, rows: TranscriptEntry[]) => { + const row = rows.find((r) => r.id === id); + if (row) baselinesRef.current[id] = cloneTranscriptEntry(row); + }, []); + + const bootstrapped = useRef(false); + + useEffect(() => { + if (!hydrated || bootstrapped.current) return; + bootstrapped.current = true; + + const edit = searchParams.get('edit'); + const intent = searchParams.get('intent') === 'new'; + + let s = resolveInitialEntrySession(entries); + const committed = entries; + + if (isFunnel) { + s = toFunnelSingleRowSession(s, committed, { + intent: intent || undefined, + edit: edit || null + }); + } else if (intent) { + s = ensureDraftEditorOpen(s, committed); + } else if (edit && s.rows.some((r) => r.id === edit)) { + s = { ...s, expandedId: edit, lastPreviewId: edit }; + } else if (s.rows.length === 0) { + s = ensureDraftEditorOpen(s, committed); + } + + if (edit || intent) { + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + next.delete('edit'); + next.delete('intent'); + return next; + }, + { replace: true } + ); + } + + setSession(s); + writeEntrySessionToStorage(s); + dispatchEntrySessionUpdated(); + + if (s.expandedId) { + captureBaseline(s.expandedId, s.rows); + prevExpandedIdRef.current = s.expandedId; + + if (isFunnel && onFunnelAutoSaveStatusChange) { + const row = s.rows.find((r) => r.id === s.expandedId); + const committedEntry = committed.find((e) => e.id === s.expandedId); + if (row && committedEntry && entryPayloadEqual(row, committedEntry)) { + onFunnelAutoSaveStatusChange({ status: 'saved', lastSavedAt: null }); + } + } + } + }, [hydrated, searchParams, setSearchParams, captureBaseline, isFunnel, onFunnelAutoSaveStatusChange]); + + const reportAutoSaveStatus = useCallback( + (status: AutoSaveStatus, lastSavedAt: Date | null = null) => { + onFunnelAutoSaveStatusChange?.({ status, lastSavedAt }); + }, + [onFunnelAutoSaveStatusChange] + ); + + const buildSavedEntry = useCallback((row: TranscriptEntry): TranscriptEntry => { + const existing = entries.find((e) => e.id === row.id); + return { + ...row, + createdAt: existing?.createdAt ?? row.createdAt, + topSkills: row.topSkills.slice(0, TOP_SKILLS_MAX) + }; + }, [entries]); + + const persistActiveRow = useCallback(async (): Promise => { + const current = sessionRef.current; + const id = current?.expandedId; + if (!id) return false; + const row = current.rows.find((r) => r.id === id); + if (!row) return false; + + const saved = buildSavedEntry(row); + const existing = entries.find((e) => e.id === id); + if (existing && entryPayloadEqual(saved, existing)) { + return true; + } + + try { + await upsertCommittedEntry(saved); + setSession((prev) => { + if (!prev) return prev; + const next = syncSessionRowsAfterUpsert(prev, saved); + return { ...next, expandedId: saved.id, lastPreviewId: saved.id }; + }); + baselinesRef.current[id] = cloneTranscriptEntry(saved); + return true; + } catch { + return false; + } + }, [buildSavedEntry, upsertCommittedEntry, entries]); + + const validateFinishRequirements = useCallback((): boolean => { + const current = sessionRef.current; + const id = current?.expandedId; + if (!id) return false; + const row = current.rows.find((r) => r.id === id); + if (!row) return false; + + if (!entryIsComplete(row, 'funnel')) { + setSaveErrorRowId(id); + setActiveStep(firstIncompleteFunnelStep(row)); + return false; + } + + setSaveErrorRowId(null); + return true; + }, []); + + useEffect(() => { + if (!isFunnel || !onRegisterFunnelFinish) return; + onRegisterFunnelFinish({ validateFinishRequirements }); + }, [isFunnel, onRegisterFunnelFinish, validateFinishRequirements]); + + useEffect(() => { + if (!isFunnel || !session?.expandedId || !onFunnelAutoSaveStatusChange) return; + + const row = session.rows.find((r) => r.id === session.expandedId); + if (!row) return; + + const saved = buildSavedEntry(row); + const committed = entries.find((e) => e.id === row.id); + if (committed && entryPayloadEqual(saved, committed)) { + return; + } + + reportAutoSaveStatus('pending'); + + const t = window.setTimeout(() => { + void (async () => { + reportAutoSaveStatus('saving'); + const ok = await persistActiveRow(); + if (ok) { + reportAutoSaveStatus('saved', new Date()); + } else { + reportAutoSaveStatus('error'); + } + })(); + }, COMMITTED_AUTOSAVE_MS); + + return () => window.clearTimeout(t); + }, [ + isFunnel, + session, + buildSavedEntry, + persistActiveRow, + reportAutoSaveStatus, + onFunnelAutoSaveStatusChange + ]); + + useEffect(() => { + if (!session) return; + const id = session.expandedId; + if (id === prevExpandedIdRef.current) return; + prevExpandedIdRef.current = id; + if (id) captureBaseline(id, session.rows); + }, [session, session?.expandedId, captureBaseline]); + + useEffect(() => { + if (!session) return; + const t = window.setTimeout(() => { + writeEntrySessionToStorage(session); + dispatchEntrySessionUpdated(); + }, 400); + return () => window.clearTimeout(t); + }, [session]); + + useEffect(() => { + if (!session) { + onExportRowsChange?.([]); + return; + } + onExportRowsChange?.(filterEntriesForExport(session.rows)); + }, [session, onExportRowsChange]); + + const patchRow = useCallback((id: string, patch: Partial) => { + setSession((prev) => { + if (!prev) return prev; + const rows = prev.rows.map((r) => { + if (r.id !== id) return r; + const nextTop = patch.topSkills ?? r.topSkills; + return { ...r, ...patch, topSkills: nextTop }; + }); + const lastPreviewId = prev.expandedId === id ? id : prev.lastPreviewId; + return { ...prev, rows, lastPreviewId }; + }); + }, []); + + const handleToggleExpand = useCallback((id: string) => { + setSession((prev) => { + if (!prev) return prev; + if (prev.expandedId === id) { + return { ...prev, expandedId: null }; + } + return { ...prev, expandedId: id, lastPreviewId: id }; + }); + setSaveErrorRowId(null); + }, []); + + const handleAdd = useCallback(() => { + const row = createEmptyTranscriptEntry(); + setSession((prev) => { + if (!prev) return prev; + return { + ...prev, + rows: [row, ...prev.rows], + expandedId: row.id, + lastPreviewId: row.id + }; + }); + setSaveErrorRowId(null); + }, []); + + const isCommittedEntryId = useCallback((id: string) => { + return committedIds.has(id); + }, [committedIds]); + + const handleCancel = useCallback( + (id: string) => { + const baseline = baselinesRef.current[id]; + setSaveErrorRowId(null); + setSession((prev) => { + if (!prev) return prev; + const committed = isCommittedEntryId(id); + const current = prev.rows.find((r) => r.id === id); + const restored = baseline ? cloneTranscriptEntry(baseline) : current ?? null; + if (!restored) { + return { ...prev, expandedId: null }; + } + let rows = prev.rows.map((r) => (r.id === id ? restored : r)); + if (!committed && !entryHasExportableContent(restored)) { + rows = rows.filter((r) => r.id !== id); + } + const lastPreviewId = + rows.length > 0 ? rows[rows.length - 1].id : null; + return { + ...prev, + rows, + expandedId: null, + lastPreviewId + }; + }); + }, + [isCommittedEntryId] + ); + + const handleDone = useCallback( + (id: string) => { + const row = sessionRef.current?.rows.find((r) => r.id === id); + if (!row) return; + if (!entryIsComplete(row, formVariant)) { + setSaveErrorRowId(id); + return; + } + setSaveErrorRowId(null); + const saved: TranscriptEntry = { + ...row, + topSkills: row.topSkills.slice(0, TOP_SKILLS_MAX) + }; + void upsertCommittedEntry(saved); + setSession((prev) => { + if (!prev) return prev; + const next = syncSessionRowsAfterUpsert(prev, saved); + return { ...next, expandedId: null }; + }); + baselinesRef.current[id] = cloneTranscriptEntry(saved); + }, + [formVariant, upsertCommittedEntry] + ); + + const displayRows = useMemo( + () => (session ? sortEntriesNewestFirst(session.rows) : []), + [session] + ); + + const funnelEntry = isFunnel ? (displayRows[0] ?? null) : null; + const funnelAnswered = funnelEntry + ? countFunnelFieldsAnswered(funnelEntry) + : 0; + const funnelCompletionBadgeBg = funnelEntry + ? CONFIDENCE_LEVEL_SOLID[ + funnelCompletionTier(funnelAnswered, FUNNEL_FORM_FIELD_TOTAL) - 1 + ] + : null; + + const expandedId = session?.expandedId ?? null; + + useLayoutEffect(() => { + if (!expandedId || !achievementListRef.current) return; + const row = achievementListRef.current.querySelector( + `[data-achievement-id="${expandedId}"]` + ); + row?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + }, [expandedId, displayRows.length]); + + if (!hydrated || !session) { + return ( +
+
+

Loading your editor…

+
+ ); + } + + return ( +
+ {isFunnel && funnelEntry && funnelCompletionBadgeBg ? ( + +
+
+ {FUNNEL_FORM_STEPS.map((step, index) => { + const answered = countFunnelStepFieldsAnswered( + index, + funnelEntry + ); + const total = countFunnelStepFieldsTotal(index); + const fillPct = + total > 0 ? (answered / total) * 100 : 0; + const isActive = index === activeStep; + return ( + + ); + })} +
+ + {funnelAnswered} / {FUNNEL_FORM_FIELD_TOTAL}{' '} + questions answered + +
+
+ ) : null} + {/* + Scroll contract: + - Editor pane: header fixed; `transcript-achievement-list` scrolls vertically. + - Preview pane: `achievements-record-preview-scroll` scrolls vertically. + - Layout chain uses min-h-0 + overflow-hidden so panes do not share one page scroll. + */} +
*]:min-h-0', + isFunnel + ? 'gap-4 bg-muted p-4 min-[900px]:grid-cols-2' + : 'bg-muted max-[899px]:grid-rows-[minmax(0,1fr)_minmax(0,1fr)] min-[900px]:grid-cols-[5fr_7fr]' + )} + > + {isFunnel ? ( + +
+
+ {displayRows.map((entry) => ( + handleToggleExpand(entry.id)} + onPatch={(patch) => patchRow(entry.id, patch)} + onCancel={undefined} + onDone={undefined} + showDoneErrors={false} + showSaveErrors={saveErrorRowId === entry.id} + activeStep={activeStep} + onActiveStepChange={setActiveStep} + onFinish={funnelOnFinish} + showDelete={committedIds.has(entry.id)} + onDeleteRequest={() => setDeleteConfirmFor(entry)} + /> + ))} +
+
+
+ ) : ( + + )} + + {isFunnel ? ( + + ) : ( +
+ +
+ )} +
+ { + if (!open) setDeleteConfirmFor(null); + }} + title="Remove this achievement?" + description={ + deleteConfirmFor + ? `“${getEntryDisplayTitle(deleteConfirmFor.programName, 'Untitled')}” will be removed from your learning record. This cannot be undone.` + : '' + } + confirmLabel="Delete" + cancelLabel="Cancel" + variant="destructive" + buttonClassName="h-10" + onConfirm={() => { + const target = deleteConfirmFor; + setDeleteConfirmFor(null); + if (!target) return; + delete baselinesRef.current[target.id]; + void deleteCommittedEntry(target.id); + setSession((prev) => { + if (!prev) return null; + const rows = prev.rows.filter((r) => r.id !== target.id); + if (rows.length === 0) return null; + const expandedId = + prev.expandedId === target.id ? null : prev.expandedId; + const lastPreviewId = + prev.lastPreviewId === target.id + ? (rows[rows.length - 1]?.id ?? null) + : prev.lastPreviewId; + return { ...prev, rows, expandedId, lastPreviewId }; + }); + }} + /> +
+ ); +} diff --git a/frontend/src/pages/student/digital-transcript/LearningRecordDocument.tsx b/frontend/src/pages/student/digital-transcript/LearningRecordDocument.tsx new file mode 100644 index 000000000..c6326c0ca --- /dev/null +++ b/frontend/src/pages/student/digital-transcript/LearningRecordDocument.tsx @@ -0,0 +1,405 @@ +import type { ReactNode } from 'react'; +import { Badge } from '@/components/ui/badge'; +import { cn } from '@/lib/utils'; +import { getEntryDisplayTitleOrNull } from './entryTitleDisplay'; +import { + confidenceScaleLabel, + DOCUMENT_PREVIEW_LABELS, + LEARNING_RECORD_PREVIEW_LABELS +} from './transcriptReflectionConfig'; +import { + countAnsweredReflections, + getLearningRecordPreviewState, + hasFilledFunnelReflectionSections, + hasFilledMetadataSections, + hasFilledNarrativeSections, + isCompletedSectionFilled, + isConfidenceSectionFilled, + isProgramSectionFilled, + isSkillsSectionFilled, + reflectionSlotsTotal, + type LearningRecordDocumentSource +} from './learningRecordDocumentModel'; +import { + LearningRecordDocumentNarrative, + type LearningRecordDocumentVariant +} from './LearningRecordDocumentNarrative'; + +function formatCompletedLong(dateStr: string): string | null { + if (!dateStr.trim()) return null; + return new Date(`${dateStr}T12:00:00`).toLocaleDateString(undefined, { + year: 'numeric', + month: 'long', + day: 'numeric' + }); +} + +function confidenceSegments(level: string): number { + if (!/^[1-5]$/.test(level)) return 0; + return Number(level); +} + +/** Scan + narrative section labels — 11px sentence case, document rhythm */ +function SectionLabel({ id, children }: { id: string; children: ReactNode }) { + return ( +

+ {children} +

+ ); +} + +function PlaceholderText({ children }: { children: ReactNode }) { + return {children}; +} + +const skeletonBarClass = 'rounded-md bg-muted/70'; + +function SkeletonBar({ className }: { className?: string }) { + return
; +} + +function SkeletonLines({ count = 4 }: { count?: number }) { + const widths = ['w-full', 'w-[92%]', 'w-[88%]', 'w-[72%]', 'w-[64%]', 'w-[80%]']; + return ( +
+ {Array.from({ length: count }, (_, i) => ( +
+ ))} +
+ ); +} + +function SkeletonHeadline() { + return ( +
+ + +
+ ); +} + +function SkeletonSkillPills() { + return ( +
+ + + +
+ ); +} + +type EmptyPreviewVariant = 'placeholder' | 'skeleton'; +export type LearningRecordDocumentLayout = 'default' | 'record'; +export type { LearningRecordDocumentVariant }; + +interface LearningRecordDocumentProps { + source: LearningRecordDocumentSource; + /** Resident display name for funnel achievement header (not from form data). */ + residentName?: string; + /** When false, hides the per-achievement readiness row (e.g. compact thumbnails). */ + showReadiness?: boolean; + /** `record` — stacked program cards in the achievements-record preview. */ + layout?: LearningRecordDocumentLayout; + /** Funnel entry — section-ordered right column; program/date only on the left. */ + documentVariant?: LearningRecordDocumentVariant; + /** Extra classes on the root article (e.g. flex layout from parent card). */ + className?: string; + /** + * When set, empty metadata and narrative slots use this label (muted italic) + * instead of the default instructional placeholders (entry-page preview). + */ + emptyAnswerLabel?: string; + /** Skeleton bars for empty slots (live achievements-record preview). */ + emptyPreviewVariant?: EmptyPreviewVariant; + /** PDF export: render only sections with answers (no placeholders or skeletons). */ + filledSectionsOnly?: boolean; +} + +export function LearningRecordDocument({ + source, + residentName = '', + showReadiness = true, + layout = 'default', + documentVariant = 'default', + className, + emptyAnswerLabel, + emptyPreviewVariant = 'placeholder', + filledSectionsOnly = false +}: LearningRecordDocumentProps) { + const state = getLearningRecordPreviewState(source); + const isFunnel = documentVariant === 'funnel'; + const isRecord = layout === 'record'; + const labels = isRecord ? LEARNING_RECORD_PREVIEW_LABELS : DOCUMENT_PREVIEW_LABELS; + const emptyPh = emptyAnswerLabel?.trim(); + const skeletonEmpty = !filledSectionsOnly && emptyPreviewVariant === 'skeleton'; + const showAllNarrativeSections = + !isFunnel && + !filledSectionsOnly && + (state !== 'empty' || Boolean(emptyPh) || skeletonEmpty); + + const showProgram = !filledSectionsOnly || isProgramSectionFilled(source); + const showCompleted = isFunnel + ? isCompletedSectionFilled(source) + : !filledSectionsOnly || isCompletedSectionFilled(source); + const showConfidence = + !isFunnel && (!filledSectionsOnly || isConfidenceSectionFilled(source)); + const showSkills = !isFunnel && (!filledSectionsOnly || isSkillsSectionFilled(source)); + const showMetadataColumn = + !isFunnel && + (!filledSectionsOnly || hasFilledMetadataSections(source)); + const showFunnelHeader = + isFunnel && + (Boolean(residentName.trim()) || + showProgram || + showCompleted || + !filledSectionsOnly); + const showNarrativeColumn = isFunnel + ? !filledSectionsOnly || hasFilledFunnelReflectionSections(source) + : !filledSectionsOnly || hasFilledNarrativeSections(source); + const singleColumn = + filledSectionsOnly && (showMetadataColumn !== showNarrativeColumn) && !isFunnel; + + function EmptySlot({ fallback, skeleton }: { fallback: ReactNode; skeleton: ReactNode }) { + if (skeletonEmpty) return <>{skeleton}; + if (emptyPh) return {emptyPh}; + return <>{fallback}; + } + const answered = countAnsweredReflections(source); + const totalSlots = reflectionSlotsTotal(); + const readinessPct = Math.round((answered / totalSlots) * 100); + const seg = confidenceSegments(source.confidence); + const dateShown = formatCompletedLong(source.completionDate); + const headlineFilled = Boolean(source.oneSentence.trim()); + const residentDisplayName = residentName.trim(); + const programDisplayTitle = getEntryDisplayTitleOrNull(source.programName); + + const narrative = ( + + ); + + return ( +
+ {showReadiness ? ( +
+
+ Reflections you have added so far + + {answered} of {totalSlots} + +
+
+
+
+
+ ) : null} + + {isFunnel ? ( + <> + {showFunnelHeader ? ( +
+ {residentDisplayName || !filledSectionsOnly ? ( +

+ {residentDisplayName || ( + Resident name + )} +

+ ) : null} + {showProgram || showCompleted ? ( +
+ {showProgram ? ( +
+ + Achievement + +

+ {programDisplayTitle ? ( + programDisplayTitle + ) : ( + Your program + } + skeleton={ + + } + /> + )} +

+
+ ) : null} + {showCompleted ? ( +
+ + {labels.completed} + +

{dateShown}

+
+ ) : null} +
+ ) : null} +
+ ) : null} + {(showNarrativeColumn || !filledSectionsOnly) && ( +
+ {narrative} +
+ )} + + ) : ( +
+ {showMetadataColumn ? ( +
+ {showProgram ? ( +
+ {labels.program} +
+ {programDisplayTitle ? ( + programDisplayTitle + ) : ( + Your program} + skeleton={} + /> + )} +
+
+ ) : null} + + {showCompleted ? ( +
+ {labels.completed} +
+ {dateShown != null && dateShown !== '' ? ( + dateShown + ) : ( + Date} + skeleton={} + /> + )} +
+
+ ) : null} + + {showConfidence ? ( +
+ {labels.confidence} +
+ {[1, 2, 3, 4, 5].map((i) => ( +
0 && i <= seg + ? 'bg-foreground/90' + : 'bg-muted/70' + )} + /> + ))} +
+ {seg > 0 ? ( +

{seg} out of 5

+ ) : skeletonEmpty ? null : ( +

+ Not selected yet +

+ )} +
+ ) : null} + + {showSkills ? ( +
+ {labels.skills} + {source.topSkills.length > 0 ? ( +
    + {source.topSkills.map((skill, idx) => ( +
  • + + {skill} + +
  • + ))} +
+ ) : ( +
+ Listed as you write} + skeleton={} + /> +
+ )} +
+ ) : null} +
+ ) : null} + + {showNarrativeColumn ? narrative : null} + +
+ )} + + {source.confidence.trim() && /^[1-5]$/.test(source.confidence) ? ( +

{confidenceScaleLabel(source.confidence)}

+ ) : null} +
+ ); +} diff --git a/frontend/src/pages/student/digital-transcript/LearningRecordDocumentNarrative.tsx b/frontend/src/pages/student/digital-transcript/LearningRecordDocumentNarrative.tsx new file mode 100644 index 000000000..73e5bf67b --- /dev/null +++ b/frontend/src/pages/student/digital-transcript/LearningRecordDocumentNarrative.tsx @@ -0,0 +1,445 @@ +import type { ReactNode } from 'react'; +import { cn } from '@/lib/utils'; +import { + CONFIDENCE_RADIO_OPTIONS, + DOCUMENT_PREVIEW_LABELS, + FUNNEL_PREVIEW_LABELS, + FUNNEL_PREVIEW_SECTIONS, + funnelPreviewFieldAnswered, + LEARNING_RECORD_PREVIEW_LABELS, + type FunnelPreviewFieldKey +} from './transcriptReflectionConfig'; +import { + isAdviceSectionFilled, + isConnectsSectionFilled, + isFinishSectionFilled, + isHeadlineSectionFilled, + isPrideSectionFilled, + isStandoutSectionFilled, + type LearningRecordDocumentSource +} from './learningRecordDocumentModel'; + +const narrativeBodyClass = + 'whitespace-pre-wrap text-[14px] font-normal leading-[1.6] text-foreground'; + +const funnelAnswerClass = + 'mb-1 whitespace-pre-wrap text-[14px] font-normal leading-[1.6] text-foreground'; + +const funnelAnswerGroupClass = '[&>*:last-child]:mb-0'; + +function SectionLabel({ + id, + children, + className +}: { + id: string; + children: ReactNode; + className?: string; +}) { + return ( +

+ {children} +

+ ); +} + +type NarrativeLabels = typeof DOCUMENT_PREVIEW_LABELS | typeof LEARNING_RECORD_PREVIEW_LABELS; + +export type LearningRecordDocumentVariant = 'default' | 'funnel'; + +interface LearningRecordDocumentNarrativeProps { + source: LearningRecordDocumentSource; + documentVariant?: LearningRecordDocumentVariant; + isRecord: boolean; + labels: NarrativeLabels; + headlineFilled: boolean; + showAllNarrativeSections: boolean; + showEmptyHint: boolean; + skeletonEmpty: boolean; + filledSectionsOnly?: boolean; + emptyPh: string | undefined; + answered: number; + totalSlots: number; + state: 'empty' | 'partial' | 'complete'; + EmptySlot: (props: { fallback: ReactNode; skeleton: ReactNode }) => ReactNode; + PlaceholderText: (props: { children: ReactNode }) => ReactNode; + SkeletonHeadline: () => ReactNode; + SkeletonLines: (props: { count?: number }) => ReactNode; +} + +function FunnelPreviewFieldContent({ + source, + field +}: { + source: LearningRecordDocumentSource; + field: FunnelPreviewFieldKey; +}) { + switch (field) { + case 'whatMadeYouFinish': + return

{source.whatMadeYouFinish.trim()}

; + case 'q4': { + const text = source.q4Text.trim(); + return ( +

{text || 'Yes'}

+ ); + } + case 'q5': + return ( +
+ {source.q5BeforeTags.length > 0 ? ( +

+ Before:{' '} + {source.q5BeforeTags.join(', ')} +

+ ) : null} + {source.q5AfterTags.length > 0 ? ( +

+ After:{' '} + {source.q5AfterTags.join(', ')} +

+ ) : null} + {source.q5FreeText.trim() ? ( +

{source.q5FreeText.trim()}

+ ) : null} +
+ ); + case 'adviceToPeer': + return

{source.adviceToPeer.trim()}

; + case 'confidence': { + const conf = source.confidence.trim(); + const confidenceRow = CONFIDENCE_RADIO_OPTIONS.find(([v]) => v === conf); + return ( +
+

+ {confidenceRow ? ( + <> + {conf} + {' — '} + {confidenceRow[1]} + + ) : ( + conf + )} +

+ {source.q7Text.trim() ? ( +

{source.q7Text.trim()}

+ ) : null} +
+ ); + } + case 'q8Selections': + case 'q9Selections': { + const items = source[field]; + return ( +
    + {items.map((item) => ( +
  • + {item} +
  • + ))} +
+ ); + } + default: + return null; + } +} + +function FunnelPreviewNarrative({ source }: { source: LearningRecordDocumentSource }) { + const showSummary = Boolean(source.oneSentence.trim()); + + return ( +
+ {showSummary ? ( +
+

+ “{source.oneSentence.trim()}” +

+
+ ) : null} + + {FUNNEL_PREVIEW_SECTIONS.map((section) => { + const sectionHasAnswers = section.fields.some((field) => + funnelPreviewFieldAnswered(source, field) + ); + if (!sectionHasAnswers) return null; + + return ( +
+
+ + {section.title} + +
+ + {section.fields.map((field) => { + if (!funnelPreviewFieldAnswered(source, field)) return null; + + const labelId = `lr-funnel-field-${field}`; + const caption = FUNNEL_PREVIEW_LABELS[field]; + + return ( +
+ + {caption} + + +
+ ); + })} +
+ ); + })} +
+ ); +} + +export function LearningRecordDocumentNarrative({ + source, + documentVariant = 'default', + isRecord, + labels, + headlineFilled, + showAllNarrativeSections, + showEmptyHint, + skeletonEmpty, + filledSectionsOnly = false, + emptyPh, + answered, + totalSlots, + state, + EmptySlot, + PlaceholderText, + SkeletonHeadline, + SkeletonLines +}: LearningRecordDocumentNarrativeProps) { + if (documentVariant === 'funnel') { + return ; + } + + const showHeadline = !filledSectionsOnly || isHeadlineSectionFilled(source); + const showPride = !filledSectionsOnly || isPrideSectionFilled(source); + const showStandout = !filledSectionsOnly || isStandoutSectionFilled(source); + const showFinish = !filledSectionsOnly || isFinishSectionFilled(source); + const showConnects = !filledSectionsOnly || isConnectsSectionFilled(source); + const showAdvice = !filledSectionsOnly || isAdviceSectionFilled(source); + + return ( +
+ {showHeadline ? ( + isRecord ? ( +
+ {headlineFilled ? ( +

+ “{source.oneSentence.trim()}” +

+ ) : ( +
+ + Your one-sentence summary will appear here. + + } + skeleton={} + /> +
+ )} +
+ ) : ( +
+ + {DOCUMENT_PREVIEW_LABELS.headline} + + {headlineFilled ? ( +

+ {source.oneSentence.trim()} +

+ ) : ( +
+ + You'll write a one-sentence summary at the end. It will + appear here. + + } + skeleton={} + /> +
+ )} +
+ ) + ) : null} + + {showEmptyHint ? ( +
+

+ Your reflections will appear here as you answer. +

+ +
+ ) : null} + + {showAllNarrativeSections || filledSectionsOnly ? ( + <> + {showPride ? ( +
+ {labels.pride} + {source.pride.trim() ? ( +

{source.pride.trim()}

+ ) : ( +
+ Add your answer on the left + } + skeleton={} + /> +
+ )} +
+ ) : null} + + {showStandout ? ( +
+ {labels.standout} + {source.standoutMoment.trim() ? ( +

{source.standoutMoment.trim()}

+ ) : ( +
+ Add your answer on the left + } + skeleton={} + /> +
+ )} +
+ ) : null} + + {showFinish ? ( +
+ {labels.finish} + {source.whatMadeYouFinish.trim() ? ( +

{source.whatMadeYouFinish.trim()}

+ ) : ( +
+ Add your answer on the left + } + skeleton={} + /> +
+ )} +
+ ) : null} + + {showConnects ? ( +
+ {labels.connects} + {source.goalConnection.trim() ? ( +

{source.goalConnection.trim()}

+ ) : ( +
+ Add your answer on the left + } + skeleton={} + /> +
+ )} +
+ ) : null} + + {showAdvice ? ( + !isRecord ? ( +
+ + {DOCUMENT_PREVIEW_LABELS.advice} + + {source.adviceToPeer.trim() ? ( +
+ {source.adviceToPeer.trim()} +
+ ) : ( +
+ + Add your answer on the left + + } + skeleton={} + /> +
+ )} +
+ ) : source.adviceToPeer.trim() || skeletonEmpty ? ( +
+ {source.adviceToPeer.trim() ? ( +
+ “{source.adviceToPeer.trim()}” +
+ ) : ( +
+ + Add your answer on the left + + } + skeleton={} + /> +
+ )} +
+ ) : null + ) : null} + + {state === 'partial' && + answered < totalSlots && + !emptyPh && + !skeletonEmpty && + !isRecord && + !filledSectionsOnly ? ( +
+

More reflections will appear as you answer.

+ +
+ ) : null} + + ) : null} +
+ ); +} diff --git a/frontend/src/pages/student/digital-transcript/LearningRecordExportContent.tsx b/frontend/src/pages/student/digital-transcript/LearningRecordExportContent.tsx new file mode 100644 index 000000000..54e584b35 --- /dev/null +++ b/frontend/src/pages/student/digital-transcript/LearningRecordExportContent.tsx @@ -0,0 +1,130 @@ +import { forwardRef } from 'react'; +import { cn } from '@/lib/utils'; +import type { TranscriptEntry } from '@/types/digital-transcript'; +import { + LearningRecordDocument, + type LearningRecordDocumentVariant +} from './LearningRecordDocument'; +import { programsCompletedLabel } from './learningRecordResidentName'; + +export function LearningRecordPreviewHeader({ + residentName, + programCount +}: { + residentName: string; + programCount: number; +}) { + return ( +
+

+ Learning record +

+

{residentName}

+

{programsCompletedLabel(programCount)}

+
+ ); +} + +export interface LearningRecordExportContentProps { + rows: TranscriptEntry[]; + residentName: string; + /** Dim non-focused achievements in the live preview */ + anchorId?: string | null; + className?: string; + /** PDF: only render sections that have answers (no skeletons or placeholders). */ + filledSectionsOnly?: boolean; + /** Live funnel preview: header is replaced by the download action in the preview pane. */ + hidePreviewHeader?: boolean; + /** Funnel live preview: parent card supplies padding and background. */ + embeddedLivePreview?: boolean; + documentVariant?: LearningRecordDocumentVariant; +} + +export const LearningRecordExportContent = forwardRef< + HTMLDivElement, + LearningRecordExportContentProps +>(function LearningRecordExportContent( + { + rows, + residentName, + anchorId = null, + className, + filledSectionsOnly = false, + hidePreviewHeader = false, + embeddedLivePreview = false, + documentVariant = 'default' + }, + ref +) { + const isFunnel = documentVariant === 'funnel'; + const highlightAnchor = Boolean(anchorId) && !filledSectionsOnly; + const showPreviewHeader = !hidePreviewHeader; + const shellClassName = embeddedLivePreview + ? 'learning-record-pdf-export bg-transparent' + : 'learning-record-pdf-export learning-record-print-root bg-background px-4 py-5 sm:px-5'; + + if (rows.length === 0) { + return ( +
+ {showPreviewHeader ? ( + + ) : null} +

+ Add an achievement on the left to see your record here. +

+
+ ); + } + + return ( +
+ {showPreviewHeader ? ( + + ) : null} +
+ {rows.map((entry) => ( +
+ +
+ ))} +
+
+ ); +}); diff --git a/frontend/src/pages/student/digital-transcript/ReflectionStepField.tsx b/frontend/src/pages/student/digital-transcript/ReflectionStepField.tsx new file mode 100644 index 000000000..bfdaf907f --- /dev/null +++ b/frontend/src/pages/student/digital-transcript/ReflectionStepField.tsx @@ -0,0 +1,177 @@ +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { cn } from '@/lib/utils'; +import type { TranscriptEntry } from '@/types/digital-transcript'; +import { ConfidenceSegmentedControl } from './ConfidenceSegmentedControl'; +import { ReflectionTextField } from './ReflectionTextField'; +import { + reflectionStepByKey, + FUNNEL_FIELD_DESCRIPTIONS, + FUNNEL_REFLECTION_TEXT_NUDGES, + getReflectionNudgeTone, + NUDGE_TONE_CLASSES, + nudgeTrackFillRatio, + type ReflectionAnswerKey, + type ReflectionTextFieldKey +} from './transcriptReflectionConfig'; +import { TopSkillsTagField } from './TopSkillsTagField'; + +interface ReflectionStepFieldProps { + entry: TranscriptEntry; + stepKey: ReflectionAnswerKey; + onChange: (patch: Partial) => void; + /** Funnel: render topSkills as plain text stored in topSkills[0]. */ + skillsAsParagraph?: boolean; + labelOverride?: string; + useFunnelNudges?: boolean; +} + +type EntryTextFieldKey = Exclude; + +function textFieldKey(key: ReflectionAnswerKey): EntryTextFieldKey | null { + if (key === 'topSkills' || key === 'confidence') return null; + return key; +} + +function FunnelSkillsInput({ + id, + label, + value, + onChange +}: { + id: string; + label: string; + value: string; + onChange: (v: string) => void; +}) { + const nudge = FUNNEL_REFLECTION_TEXT_NUDGES.topSkillsParagraph; + const len = value.length; + const tone = getReflectionNudgeTone(len, nudge); + const toneCls = NUDGE_TONE_CLASSES[tone]; + const fill = nudgeTrackFillRatio(len, nudge); + + function handleChange(next: string) { + if (next.length <= nudge.maxLength) onChange(next); + else onChange(next.slice(0, nudge.maxLength)); + } + + return ( +
+ +

{nudge.hint}

+
+