feat(cohorts): rename Programs to Cohorts with data migration (#694)

* feat(cohorts): rename Programs to Cohorts with data migration

- Add cohort database tables and Drizzle migration alongside legacy program tables
- Introduce migrate-programs-to-cohorts script to copy existing program data safely
- Rename API routes (/cohort), services, queries, validation, and error codes
- Rename dashboard feature module, routes (/cohorts), navigation, and translations
- Update docs, emails, and internal compliance cron endpoints for cohort terminology
- Keep legacy program tables in place for rollback until explicitly removed

Co-authored-by: Rotimi Best <[email protected]>

* fix(db): restore migrate:answer-data script alongside cohort migration

Co-authored-by: Rotimi Best <[email protected]>

* fix(db): cast program enums when migrating data to cohort tables

Co-authored-by: Rotimi Best <[email protected]>

* fix(db): truncate cohort tables before program-to-cohort copy

Co-authored-by: Rotimi Best <[email protected]>

* fix(db): report accurate row counts in programs-to-cohorts migration

Co-authored-by: Rotimi Best <[email protected]>

---------

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>
This commit is contained in:
Rotimi Best
2026-07-02 12:10:31 +01:00
committed by GitHub
co-authored by Rotimi Best Cursor Agent
parent fe9f8c441f
commit ec61bfe7d9
115 changed files with 19703 additions and 6046 deletions
@@ -16,23 +16,23 @@
title: string;
}
interface Program {
interface Cohort {
id: string;
name: string;
}
interface Props {
courses: Course[];
programs: Program[];
cohorts: Cohort[];
}
let { courses, programs }: Props = $props();
let { courses, cohorts }: Props = $props();
let recipientCsv = $state('');
let courseAccessMode = $state('none');
let programAccessMode = $state('none');
let cohortAccessMode = $state('none');
let selectedCourseIds = new SvelteSet<string>();
let selectedProgramIds = new SvelteSet<string>();
let selectedCohortIds = new SvelteSet<string>();
let sendEmail = $state(true);
let isSubmitting = $state(false);
@@ -44,11 +44,11 @@
}
}
function toggleProgram(programId: string) {
if (selectedProgramIds.has(programId)) {
selectedProgramIds.delete(programId);
function toggleCohort(cohortId: string) {
if (selectedCohortIds.has(cohortId)) {
selectedCohortIds.delete(cohortId);
} else {
selectedProgramIds.add(programId);
selectedCohortIds.add(cohortId);
}
}
@@ -68,9 +68,9 @@
const result = await orgApi.importAudienceMembers({
recipientCsv,
allCourses: courseAccessMode === 'all',
allPrograms: programAccessMode === 'all',
allCohorts: cohortAccessMode === 'all',
courseIds: courseAccessMode === 'select' ? [...selectedCourseIds] : undefined,
programIds: programAccessMode === 'select' ? [...selectedProgramIds] : undefined,
cohortIds: cohortAccessMode === 'select' ? [...selectedCohortIds] : undefined,
sendEmail
});
@@ -128,32 +128,32 @@
</div>
<div class="space-y-3">
<Label class="text-sm font-medium">{$t('audience.import.program_access')}</Label>
<RadioGroup.Root bind:value={programAccessMode} class="space-y-2">
<Label class="text-sm font-medium">{$t('audience.import.cohort_access')}</Label>
<RadioGroup.Root bind:value={cohortAccessMode} class="space-y-2">
<div class="flex items-center gap-2">
<RadioGroup.Item value="none" id="program-none" />
<Label for="program-none" class="font-normal">{$t('audience.import.no_programs')}</Label>
<RadioGroup.Item value="none" id="cohort-none" />
<Label for="cohort-none" class="font-normal">{$t('audience.import.no_cohorts')}</Label>
</div>
<div class="flex items-center gap-2">
<RadioGroup.Item value="all" id="program-all" />
<Label for="program-all" class="font-normal">{$t('audience.import.all_programs')}</Label>
<RadioGroup.Item value="all" id="cohort-all" />
<Label for="cohort-all" class="font-normal">{$t('audience.import.all_cohorts')}</Label>
</div>
<div class="flex items-center gap-2">
<RadioGroup.Item value="select" id="program-select" />
<Label for="program-select" class="font-normal">{$t('audience.import.select_programs')}</Label>
<RadioGroup.Item value="select" id="cohort-select" />
<Label for="cohort-select" class="font-normal">{$t('audience.import.select_cohorts')}</Label>
</div>
</RadioGroup.Root>
{#if programAccessMode === 'select'}
{#if cohortAccessMode === 'select'}
<MultiSelectList
class="ml-6"
listClass="max-h-40"
heading={$t('audience.import.select_programs')}
emptyMessage={$t('audience.import.select_programs_placeholder')}
items={programs.map((p) => ({ id: p.id, label: p.name || p.id }))}
isSelected={(id) => selectedProgramIds.has(id)}
onToggle={toggleProgram}
namePrefix="import-program"
heading={$t('audience.import.select_cohorts')}
emptyMessage={$t('audience.import.select_cohorts_placeholder')}
items={cohorts.map((p) => ({ id: p.id, label: p.name || p.id }))}
isSelected={(id) => selectedCohortIds.has(id)}
onToggle={toggleCohort}
namePrefix="import-cohort"
/>
{/if}
</div>
@@ -0,0 +1,376 @@
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
import type { Cohort, CohortDetail, CohortMember, CohortCourse, CohortNewsfeed } from '../utils/types';
import type {
TCreateCohort,
TUpdateCohort,
TAddCohortMembers,
TUpdateCohortMember,
TAddCourseToCohort,
TInviteStudentsToCohort,
TAssignExistingStudentsToCohort
} from '@cio/utils/validation/cohort';
import { currentOrg } from '$lib/utils/store/org';
import { get } from 'svelte/store';
import { isStudentExperience } from '$lib/utils/store/app';
import { snackbar } from '$features/ui/snackbar/store';
import { t } from '$lib/utils/functions/translations';
class CohortApi extends BaseApiWithErrors {
cohorts = $state<Cohort[]>([]);
cohort = $state<CohortDetail | null>(null);
members = $state<CohortMember[]>([]);
courses = $state<CohortCourse[]>([]);
newsfeed = $state<CohortNewsfeed | null>(null);
isCohortShellLoading = $state(false);
currentCohortId = $state<string | null>(null);
loadedCohortId = $state<string | null>(null);
loadedMembersCohortId = $state<string | null>(null);
loadedCoursesCohortId = $state<string | null>(null);
loadedCoursesStudentExperience = $state<boolean | null>(null);
loadedNewsfeedCohortId = $state<string | null>(null);
syncCohortCourseCount(cohortId: string, courseCount: number) {
this.cohorts = this.cohorts.map((item) => (item.id === cohortId ? { ...item, courseCount } : item));
}
async listCohorts() {
const org = get(currentOrg);
if (!org.id) return;
await this.execute<typeof classroomio.cohort.$get>({
requestFn: () => classroomio.cohort.$get({ query: { organizationId: org.id } }),
onSuccess: (data) => {
this.cohorts = data.data;
},
logContext: 'listCohorts'
});
}
async getCohort(cohortId: string, force = false) {
if (!force && this.loadedCohortId === cohortId) return;
await this.execute<(typeof classroomio.cohort)[':cohortId']['$get']>({
requestFn: () => classroomio.cohort[':cohortId'].$get({ param: { cohortId } }),
onSuccess: (data) => {
this.cohort = data.data;
this.loadedCohortId = cohortId;
},
logContext: 'getCohort'
});
}
resetCohortShell(cohortId?: string) {
this.currentCohortId = cohortId ?? null;
this.cohort = null;
this.members = [];
this.courses = [];
this.newsfeed = null;
this.loadedCohortId = null;
this.loadedMembersCohortId = null;
this.loadedCoursesCohortId = null;
this.loadedCoursesStudentExperience = null;
this.loadedNewsfeedCohortId = null;
}
async ensureCohortShell(cohortId: string, force = false) {
const studentExperience = get(isStudentExperience);
const isShellReady =
this.loadedCohortId === cohortId &&
this.loadedMembersCohortId === cohortId &&
this.loadedCoursesCohortId === cohortId &&
this.loadedCoursesStudentExperience === studentExperience;
if (!force && isShellReady) {
return;
}
const isCohortChanged = this.currentCohortId !== cohortId;
if (force || isCohortChanged) {
this.resetCohortShell(cohortId);
}
this.currentCohortId = cohortId;
this.isCohortShellLoading = true;
try {
await Promise.all([
this.getCohort(cohortId, force),
this.listMembers(cohortId, force),
this.listCourses(cohortId, force)
]);
} finally {
this.isCohortShellLoading = false;
}
}
async createCohort(data: TCreateCohort) {
const org = get(currentOrg);
if (!org.id) return;
await this.execute<typeof classroomio.cohort.$post>({
requestFn: () =>
classroomio.cohort.$post({
json: { ...data, organizationId: org.id }
}),
onSuccess: (res) => {
this.cohorts = [res.data, ...this.cohorts];
snackbar.success(t.get('cohorts.create_success'));
},
logContext: 'createCohort'
});
}
async updateCohort(cohortId: string, data: TUpdateCohort) {
await this.execute<(typeof classroomio.cohort)[':cohortId']['$put']>({
requestFn: () => classroomio.cohort[':cohortId'].$put({ param: { cohortId }, json: data }),
onSuccess: (res) => {
this.cohort = res.data;
this.cohorts = this.cohorts.map((p) => (p.id === cohortId ? { ...p, ...res.data } : p));
snackbar.success(t.get('cohorts.update_success'));
},
onError: (result) => {
const message = typeof result === 'string' ? result : result.error;
snackbar.error(message || 'Failed to update cohort');
},
logContext: 'updateCohort'
});
}
async deleteCohort(cohortId: string) {
await this.execute<(typeof classroomio.cohort)[':cohortId']['$delete']>({
requestFn: () => classroomio.cohort[':cohortId'].$delete({ param: { cohortId } }),
onSuccess: () => {
this.cohorts = this.cohorts.filter((p) => p.id !== cohortId);
snackbar.success(t.get('cohorts.delete_success'));
},
logContext: 'deleteCohort'
});
}
// Members
async listMembers(cohortId: string, force = false) {
if (!force && this.loadedMembersCohortId === cohortId) return;
await this.execute<(typeof classroomio.cohort)[':cohortId']['members']['$get']>({
requestFn: () => classroomio.cohort[':cohortId'].members.$get({ param: { cohortId } }),
onSuccess: (data) => {
this.members = data.data;
this.loadedMembersCohortId = cohortId;
},
logContext: 'listCohortMembers'
});
}
async addMembers(cohortId: string, data: TAddCohortMembers) {
await this.execute<(typeof classroomio.cohort)[':cohortId']['members']['$post']>({
requestFn: () => classroomio.cohort[':cohortId'].members.$post({ param: { cohortId }, json: data }),
onSuccess: async () => {
await this.listMembers(cohortId, true);
snackbar.success(t.get('cohorts.members_added') || 'Members added');
},
logContext: 'addCohortMembers'
});
}
async updateMember(cohortId: string, memberId: string, data: TUpdateCohortMember) {
await this.execute<(typeof classroomio.cohort)[':cohortId']['members'][':memberId']['$put']>({
requestFn: () =>
classroomio.cohort[':cohortId'].members[':memberId'].$put({
param: { cohortId, memberId },
json: data
}),
onSuccess: (res) => {
this.members = this.members.map((m) => (m.id === memberId ? { ...m, ...res.data } : m));
},
logContext: 'updateCohortMember'
});
}
async removeMember(cohortId: string, memberId: string) {
await this.execute<(typeof classroomio.cohort)[':cohortId']['members'][':memberId']['$delete']>({
requestFn: () =>
classroomio.cohort[':cohortId'].members[':memberId'].$delete({
param: { cohortId, memberId }
}),
onSuccess: () => {
this.members = this.members.filter((m) => m.id !== memberId);
snackbar.success(t.get('cohorts.member_removed') || 'Member removed');
},
logContext: 'removeCohortMember'
});
}
// Courses
async listCourses(cohortId: string, force = false) {
const studentExperience = get(isStudentExperience);
if (
!force &&
this.loadedCoursesCohortId === cohortId &&
this.loadedCoursesStudentExperience === studentExperience
) {
return;
}
await this.execute<(typeof classroomio.cohort)[':cohortId']['courses']['$get']>({
requestFn: () => classroomio.cohort[':cohortId'].courses.$get({ param: { cohortId } }),
onSuccess: (data) => {
this.courses = data.data;
this.syncCohortCourseCount(cohortId, data.data.length);
this.loadedCoursesCohortId = cohortId;
this.loadedCoursesStudentExperience = studentExperience;
},
logContext: 'listCohortCourses'
});
}
async addCourse(cohortId: string, data: TAddCourseToCohort) {
await this.execute<(typeof classroomio.cohort)[':cohortId']['courses']['$post']>({
requestFn: () => classroomio.cohort[':cohortId'].courses.$post({ param: { cohortId }, json: data }),
onSuccess: async () => {
await this.listCourses(cohortId, true);
this.syncCohortCourseCount(cohortId, this.courses.length);
snackbar.success(t.get('cohorts.course_added') || 'Course added');
},
logContext: 'addCourseToCohort'
});
}
async addCourses(cohortId: string, courseIds: string[]) {
if (courseIds.length === 0) {
return false;
}
this.isLoading = true;
this.error = null;
this.success = false;
try {
for (const courseId of courseIds) {
const response = await classroomio.cohort[':cohortId'].courses.$post({
param: { cohortId },
json: { courseId }
});
const result = (await response.json()) as { success?: boolean; error?: string };
if (!result.success) {
this.error = result.error ?? 'Failed to add course to cohort';
return false;
}
}
await this.listCourses(cohortId, true);
this.syncCohortCourseCount(cohortId, this.courses.length);
const successMessage =
courseIds.length === 1
? t.get('cohorts.course_added') || 'Course added'
: t.get('cohorts.multiple_added') || 'Courses added';
snackbar.success(successMessage);
this.success = true;
return true;
} catch (error) {
console.error('Error in addCoursesToCohort:', error);
this.error = error instanceof Error ? error.message : 'Failed to add courses to cohort';
return false;
} finally {
this.isLoading = false;
}
}
async removeCourse(cohortId: string, courseId: string) {
await this.execute<(typeof classroomio.cohort)[':cohortId']['courses'][':courseId']['$delete']>({
requestFn: () =>
classroomio.cohort[':cohortId'].courses[':courseId'].$delete({
param: { cohortId, courseId }
}),
onSuccess: () => {
this.courses = this.courses.filter((c) => c.courseId !== courseId);
this.syncCohortCourseCount(cohortId, this.courses.length);
snackbar.success(t.get('cohorts.course_removed') || 'Course removed');
},
logContext: 'removeCourseFromCohort'
});
}
// Invitations (program-scoped)
/**
* Invite new students to a cohort by CSV. Goes through the cohort-scoped
* endpoint so a Cohort ADMIN/TUTOR can invite without org-admin rights.
* Returns whether the call succeeded so callers can refresh their lists.
*/
async inviteStudentsToCohort(cohortId: string, data: TInviteStudentsToCohort): Promise<boolean> {
let ok = false;
await this.execute<(typeof classroomio.cohort)[':cohortId']['invite']['$post']>({
requestFn: () => classroomio.cohort[':cohortId'].invite.$post({ param: { cohortId }, json: data }),
onSuccess: async () => {
await this.listMembers(cohortId, true);
snackbar.success(t.get('cohorts.invite_sent') || 'Invites sent');
ok = true;
},
logContext: 'inviteStudentsToCohort'
});
return ok;
}
/**
* Assign existing org audience student profiles to this cohort.
* Same scoping as `inviteStudentsToCohort` — caller only needs program-team
* membership for this cohort.
*/
async assignExistingStudentsToCohort(cohortId: string, data: TAssignExistingStudentsToCohort): Promise<boolean> {
let ok = false;
await this.execute<(typeof classroomio.cohort)[':cohortId']['invite']['assign']['$post']>({
requestFn: () => classroomio.cohort[':cohortId'].invite.assign.$post({ param: { cohortId }, json: data }),
onSuccess: async () => {
await this.listMembers(cohortId, true);
snackbar.success(t.get('cohorts.members_added') || 'Members added');
ok = true;
},
logContext: 'assignExistingStudentsToCohort'
});
return ok;
}
// Newsfeed
async listNewsfeed(cohortId: string, force = false, cursor?: string) {
if (!force && this.loadedNewsfeedCohortId === cohortId) return;
await this.execute<(typeof classroomio.cohort)[':cohortId']['newsfeed']['$get']>({
requestFn: () =>
classroomio.cohort[':cohortId'].newsfeed.$get({
param: { cohortId },
query: { limit: '10', ...(cursor ? { cursor } : {}) }
}),
onSuccess: (data) => {
this.newsfeed = data.data;
this.loadedNewsfeedCohortId = cohortId;
},
logContext: 'listCohortNewsfeed'
});
}
// LMS — enrolled cohorts
async listEnrolledCohorts() {
await this.execute<typeof classroomio.cohort.enrolled.$get>({
requestFn: () => classroomio.cohort.enrolled.$get({}),
onSuccess: (data) => {
this.cohorts = data.data;
},
logContext: 'listEnrolledCohorts'
});
}
}
export const cohortApi = new CohortApi();
@@ -0,0 +1,129 @@
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
import type { TCreateCohortGoal, TUpdateCohortGoal } from '@cio/utils/validation/cohort';
import type { GoalsOverviewRow, MyGoalAssignment, CohortGoal } from '../utils/types';
import { snackbar } from '$features/ui/snackbar/store';
import { t } from '$lib/utils/functions/translations';
import { currentOrg } from '$lib/utils/store/org';
import { get } from 'svelte/store';
class CohortGoalApi extends BaseApiWithErrors {
goalsByCohortId = $state<Record<string, CohortGoal[]>>({});
myGoals = $state<MyGoalAssignment[]>([]);
overviewRows = $state<GoalsOverviewRow[]>([]);
loadedCohortId = $state<string | null>(null);
goalsFor(cohortId: string): CohortGoal[] {
return this.goalsByCohortId[cohortId] ?? [];
}
async listGoals(cohortId: string, force = false) {
if (!force && this.goalsByCohortId[cohortId] && this.loadedCohortId === cohortId) return;
await this.execute<(typeof classroomio.cohort)[':cohortId']['goals']['$get']>({
requestFn: () => classroomio.cohort[':cohortId'].goals.$get({ param: { cohortId } }),
onSuccess: (res) => {
this.goalsByCohortId = { ...this.goalsByCohortId, [cohortId]: res.data };
this.loadedCohortId = cohortId;
},
logContext: 'listCohortGoals'
});
}
async createGoal(cohortId: string, data: TCreateCohortGoal): Promise<boolean> {
let ok = false;
await this.execute<(typeof classroomio.cohort)[':cohortId']['goals']['$post']>({
requestFn: () => classroomio.cohort[':cohortId'].goals.$post({ param: { cohortId }, json: data }),
onSuccess: async () => {
await this.listGoals(cohortId, true);
snackbar.success(t.get('cohorts.goals.created') || 'Goal created');
ok = true;
},
logContext: 'createCohortGoal'
});
return ok;
}
async updateGoal(cohortId: string, goalId: string, data: TUpdateCohortGoal): Promise<boolean> {
let ok = false;
await this.execute<(typeof classroomio.cohort)[':cohortId']['goals'][':goalId']['$put']>({
requestFn: () =>
classroomio.cohort[':cohortId'].goals[':goalId'].$put({
param: { cohortId, goalId },
json: data
}),
onSuccess: async () => {
await this.listGoals(cohortId, true);
snackbar.success(t.get('cohorts.goals.updated') || 'Goal updated');
ok = true;
},
logContext: 'updateCohortGoal'
});
return ok;
}
async deleteGoal(cohortId: string, goalId: string): Promise<boolean> {
let ok = false;
await this.execute<(typeof classroomio.cohort)[':cohortId']['goals'][':goalId']['$delete']>({
requestFn: () =>
classroomio.cohort[':cohortId'].goals[':goalId'].$delete({
param: { cohortId, goalId }
}),
onSuccess: async () => {
await this.listGoals(cohortId, true);
snackbar.success(t.get('cohorts.goals.deleted') || 'Goal deleted');
ok = true;
},
logContext: 'deleteCohortGoal'
});
return ok;
}
async evaluateGoal(cohortId: string, goalId: string): Promise<boolean> {
let ok = false;
await this.execute<(typeof classroomio.cohort)[':cohortId']['goals'][':goalId']['evaluate']['$post']>({
requestFn: () =>
classroomio.cohort[':cohortId'].goals[':goalId'].evaluate.$post({
param: { cohortId, goalId }
}),
onSuccess: async () => {
await this.listGoals(cohortId, true);
ok = true;
},
logContext: 'evaluateCohortGoal'
});
return ok;
}
async listMyGoals() {
await this.execute<typeof classroomio.cohort.my.goals.$get>({
requestFn: () => classroomio.cohort.my.goals.$get({}),
onSuccess: (res) => {
this.myGoals = res.data;
},
logContext: 'listMyGoals'
});
}
async listOverview() {
const org = get(currentOrg);
if (!org.id) return;
await this.execute<typeof classroomio.cohort.goals.overview.$get>({
requestFn: () => classroomio.cohort.goals.overview.$get({ query: { organizationId: org.id } }),
onSuccess: (res) => {
this.overviewRows = res.data.goals;
},
logContext: 'listGoalsOverview'
});
}
}
export const cohortGoalApi = new CohortGoalApi();
@@ -0,0 +1,3 @@
export { cohortApi } from './cohort.svelte';
export { cohortGoalApi } from './goal.svelte';
export { cohortNewsfeedApi, type CohortNewsfeedCommentsByFeedId } from './newsfeed.svelte';
@@ -1,35 +1,35 @@
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
import type {
CreateProgramNewsfeedCommentRequest,
CreateProgramNewsfeedRequest,
DeleteProgramNewsfeedCommentRequest,
DeleteProgramNewsfeedRequest,
GetProgramNewsfeedCommentsRequest,
ListProgramNewsfeed,
ListProgramNewsfeedRequest,
CreateCohortNewsfeedCommentRequest,
CreateCohortNewsfeedRequest,
DeleteCohortNewsfeedCommentRequest,
DeleteCohortNewsfeedRequest,
GetCohortNewsfeedCommentsRequest,
ListCohortNewsfeed,
ListCohortNewsfeedRequest,
ProgramNewsfeedComment,
ReactToProgramNewsfeedRequest,
UpdateProgramNewsfeedRequest
ReactToCohortNewsfeedRequest,
UpdateCohortNewsfeedRequest
} from '../utils/types';
import type {
TCreateProgramNewsfeed,
TCreateProgramNewsfeedComment,
TUpdateProgramNewsfeed,
TUpdateProgramReaction
} from '@cio/utils/validation/program';
TCreateCohortNewsfeed,
TCreateCohortNewsfeedComment,
TUpdateCohortNewsfeed,
TUpdateCohortReaction
} from '@cio/utils/validation/cohort';
import {
ZCreateProgramNewsfeed,
ZCreateProgramNewsfeedComment,
ZUpdateProgramNewsfeed,
ZUpdateProgramReaction
} from '@cio/utils/validation/program';
ZCreateCohortNewsfeed,
ZCreateCohortNewsfeedComment,
ZUpdateCohortNewsfeed,
ZUpdateCohortReaction
} from '@cio/utils/validation/cohort';
import { get } from 'svelte/store';
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
import { profile } from '$lib/utils/store/user';
import { snackbar } from '$features/ui/snackbar/store';
export type ProgramNewsfeedCommentsByFeedId = {
export type CohortNewsfeedCommentsByFeedId = {
items: ProgramNewsfeedComment[];
totalCount: number;
hasMore: boolean;
@@ -37,10 +37,10 @@ export type ProgramNewsfeedCommentsByFeedId = {
cursor: string | null;
};
export class ProgramNewsfeedApi extends BaseApiWithErrors {
feeds = $state<ListProgramNewsfeed>([]);
export class CohortNewsfeedApi extends BaseApiWithErrors {
feeds = $state<ListCohortNewsfeed>([]);
commentsByFeedId = $state<Record<string, ProgramNewsfeedCommentsByFeedId>>({});
commentsByFeedId = $state<Record<string, CohortNewsfeedCommentsByFeedId>>({});
isNewFeedModalOpen = $state(false);
@@ -52,14 +52,14 @@ export class ProgramNewsfeedApi extends BaseApiWithErrors {
this.isNewFeedModalOpen = false;
}
async list(programId: string) {
await this.execute<ListProgramNewsfeedRequest>({
async list(cohortId: string) {
await this.execute<ListCohortNewsfeedRequest>({
requestFn: () =>
classroomio.program[':programId'].newsfeed.$get({
param: { programId },
classroomio.cohort[':cohortId'].newsfeed.$get({
param: { cohortId },
query: {}
}),
logContext: 'listing program newsfeed',
logContext: 'listing cohort newsfeed',
onSuccess: (response) => {
this.feeds = response.data.items || [];
this.commentsByFeedId = {};
@@ -69,36 +69,36 @@ export class ProgramNewsfeedApi extends BaseApiWithErrors {
}
},
onError: () => {
snackbar.error('snackbar.program.error.news_feed_fail');
snackbar.error('snackbar.cohort.error.news_feed_fail');
}
});
}
async create(programId: string, fields: TCreateProgramNewsfeed) {
async create(cohortId: string, fields: TCreateCohortNewsfeed) {
this.success = false;
if (isHtmlValueEmpty(fields.content)) {
this.errors = mapZodErrorsToTranslations(
ZCreateProgramNewsfeed.safeParse({ ...fields, content: '' }).error!,
ZCreateCohortNewsfeed.safeParse({ ...fields, content: '' }).error!,
'newsfeed'
);
return;
}
const validationResult = ZCreateProgramNewsfeed.safeParse(fields);
const validationResult = ZCreateCohortNewsfeed.safeParse(fields);
if (!validationResult.success) {
this.errors = mapZodErrorsToTranslations(validationResult.error, 'newsfeed');
return;
}
await this.execute<CreateProgramNewsfeedRequest>({
await this.execute<CreateCohortNewsfeedRequest>({
requestFn: () =>
classroomio.program[':programId'].newsfeed.$post({
param: { programId },
classroomio.cohort[':cohortId'].newsfeed.$post({
param: { cohortId },
json: fields
}),
logContext: 'creating program newsfeed',
logContext: 'creating cohort newsfeed',
onSuccess: (response) => {
const author = get(profile);
const newFeed = {
@@ -112,36 +112,36 @@ export class ProgramNewsfeedApi extends BaseApiWithErrors {
this.feeds = [newFeed, ...this.feeds];
this.initComments(newFeed.id, 0);
snackbar.success('snackbar.program.success.post_created');
snackbar.success('snackbar.cohort.success.post_created');
}
});
}
async update(programId: string, feedId: string, fields: TUpdateProgramNewsfeed) {
async update(cohortId: string, feedId: string, fields: TUpdateCohortNewsfeed) {
this.success = false;
if (fields.content !== undefined && isHtmlValueEmpty(fields.content)) {
this.errors = mapZodErrorsToTranslations(
ZUpdateProgramNewsfeed.safeParse({ ...fields, content: '' }).error!,
ZUpdateCohortNewsfeed.safeParse({ ...fields, content: '' }).error!,
'newsfeed'
);
return;
}
const validationResult = ZUpdateProgramNewsfeed.safeParse(fields);
const validationResult = ZUpdateCohortNewsfeed.safeParse(fields);
if (!validationResult.success) {
this.errors = mapZodErrorsToTranslations(validationResult.error, 'newsfeed');
return;
}
await this.execute<UpdateProgramNewsfeedRequest>({
await this.execute<UpdateCohortNewsfeedRequest>({
requestFn: () =>
classroomio.program[':programId'].newsfeed[':feedId'].$put({
param: { programId, feedId },
classroomio.cohort[':cohortId'].newsfeed[':feedId'].$put({
param: { cohortId, feedId },
json: fields
}),
logContext: 'updating program newsfeed',
logContext: 'updating cohort newsfeed',
onSuccess: (response) => {
const index = this.feeds.findIndex((feed) => feed.id === feedId);
if (index === -1) {
@@ -159,26 +159,26 @@ export class ProgramNewsfeedApi extends BaseApiWithErrors {
commentCount: existingFeed.commentCount
};
snackbar.success('snackbar.program.success.post_updated');
snackbar.success('snackbar.cohort.success.post_updated');
}
});
}
async react(programId: string, feedId: string, reaction: TUpdateProgramReaction['reaction']) {
const validationResult = ZUpdateProgramReaction.safeParse({ reaction });
async react(cohortId: string, feedId: string, reaction: TUpdateCohortReaction['reaction']) {
const validationResult = ZUpdateCohortReaction.safeParse({ reaction });
if (!validationResult.success) {
this.errors = mapZodErrorsToTranslations(validationResult.error, 'newsfeed');
return;
}
await this.execute<ReactToProgramNewsfeedRequest>({
await this.execute<ReactToCohortNewsfeedRequest>({
requestFn: () =>
classroomio.program[':programId'].newsfeed[':feedId'].react.$put({
param: { programId, feedId },
classroomio.cohort[':cohortId'].newsfeed[':feedId'].react.$put({
param: { cohortId, feedId },
json: validationResult.data
}),
logContext: 'reacting to program newsfeed',
logContext: 'reacting to cohort newsfeed',
onSuccess: (response) => {
const index = this.feeds.findIndex((feed) => feed.id === feedId);
if (index === -1) {
@@ -197,25 +197,25 @@ export class ProgramNewsfeedApi extends BaseApiWithErrors {
};
},
onError: () => {
snackbar.error('snackbar.program.error.reaction_error');
snackbar.error('snackbar.cohort.error.reaction_error');
}
});
}
async delete(programId: string, feedId: string) {
await this.execute<DeleteProgramNewsfeedRequest>({
async delete(cohortId: string, feedId: string) {
await this.execute<DeleteCohortNewsfeedRequest>({
requestFn: () =>
classroomio.program[':programId'].newsfeed[':feedId'].$delete({
param: { programId, feedId }
classroomio.cohort[':cohortId'].newsfeed[':feedId'].$delete({
param: { cohortId, feedId }
}),
logContext: 'deleting program newsfeed',
logContext: 'deleting cohort newsfeed',
onSuccess: () => {
this.feeds = this.feeds.filter((feed) => feed.id !== feedId);
const { [feedId]: _removedComments, ...remainingComments } = this.commentsByFeedId;
this.commentsByFeedId = remainingComments;
snackbar.success('snackbar.program.success.feed_delete_success');
snackbar.success('snackbar.cohort.success.feed_delete_success');
}
});
}
@@ -235,19 +235,19 @@ export class ProgramNewsfeedApi extends BaseApiWithErrors {
this.commentsByFeedId[feedId].totalCount = totalCount;
}
async getComments(programId: string, feedId: string) {
async getComments(cohortId: string, feedId: string) {
if (!this.commentsByFeedId[feedId]) {
this.initComments(feedId, 0);
}
this.commentsByFeedId[feedId].isLoading = true;
await this.execute<GetProgramNewsfeedCommentsRequest>({
await this.execute<GetCohortNewsfeedCommentsRequest>({
requestFn: () =>
classroomio.program[':programId'].newsfeed[':feedId'].comments.$get({
param: { programId, feedId }
classroomio.cohort[':cohortId'].newsfeed[':feedId'].comments.$get({
param: { cohortId, feedId }
}),
logContext: 'fetching program newsfeed comments',
logContext: 'fetching cohort newsfeed comments',
onSuccess: (response) => {
this.commentsByFeedId[feedId] = {
items: response.data,
@@ -264,27 +264,27 @@ export class ProgramNewsfeedApi extends BaseApiWithErrors {
}
async createComment(
programId: string,
cohortId: string,
feedId: string,
content: string,
author: { id: string; username: string; fullname: string; avatarUrl: string }
) {
const validationResult = ZCreateProgramNewsfeedComment.safeParse({
const validationResult = ZCreateCohortNewsfeedComment.safeParse({
content: getTextFromHTML(content)
} satisfies TCreateProgramNewsfeedComment);
} satisfies TCreateCohortNewsfeedComment);
if (!validationResult.success) {
this.errors = mapZodErrorsToTranslations(validationResult.error, 'newsfeed');
return;
}
await this.execute<CreateProgramNewsfeedCommentRequest>({
await this.execute<CreateCohortNewsfeedCommentRequest>({
requestFn: () =>
classroomio.program[':programId'].newsfeed[':feedId'].comment.$post({
param: { programId, feedId },
classroomio.cohort[':cohortId'].newsfeed[':feedId'].comment.$post({
param: { cohortId, feedId },
json: { content }
}),
logContext: 'creating program newsfeed comment',
logContext: 'creating cohort newsfeed comment',
onSuccess: (response) => {
const existingCommentState = this.commentsByFeedId[feedId];
if (!existingCommentState) {
@@ -313,18 +313,18 @@ export class ProgramNewsfeedApi extends BaseApiWithErrors {
};
}
snackbar.success('snackbar.program.success.comment_added');
snackbar.success('snackbar.cohort.success.comment_added');
}
});
}
async deleteComment(programId: string, feedId: string, commentId: string) {
await this.execute<DeleteProgramNewsfeedCommentRequest>({
async deleteComment(cohortId: string, feedId: string, commentId: string) {
await this.execute<DeleteCohortNewsfeedCommentRequest>({
requestFn: () =>
classroomio.program[':programId'].newsfeed[':feedId'].comment[':commentId'].$delete({
param: { programId, feedId, commentId }
classroomio.cohort[':cohortId'].newsfeed[':feedId'].comment[':commentId'].$delete({
param: { cohortId, feedId, commentId }
}),
logContext: 'deleting program newsfeed comment',
logContext: 'deleting cohort newsfeed comment',
onSuccess: () => {
const existingCommentState = this.commentsByFeedId[feedId];
if (!existingCommentState) {
@@ -345,10 +345,10 @@ export class ProgramNewsfeedApi extends BaseApiWithErrors {
};
}
snackbar.success('snackbar.program.success.comment_deleted');
snackbar.success('snackbar.cohort.success.comment_deleted');
}
});
}
}
export const programNewsfeedApi = new ProgramNewsfeedApi();
export const cohortNewsfeedApi = new CohortNewsfeedApi();
@@ -6,24 +6,24 @@
import { Button } from '@cio/ui/base/button';
import { MultiSelectList } from '@cio/ui/custom/multi-select-list';
import { t } from '$lib/utils/functions/translations';
import { programApi } from '../api';
import { cohortApi } from '../api';
import { coursesApi } from '$features/course/api';
interface Props {
open: boolean;
programId: string;
cohortId: string;
}
const COURSE_PAGE_SIZE = 20;
let { open = $bindable(false), programId }: Props = $props();
let { open = $bindable(false), cohortId }: Props = $props();
let searchValue = $state('');
let currentPage = $state(1);
const selectedCourseIds = new SvelteSet<string>();
const availableCourses = $derived.by(() => {
const existingCourseIds = new Set(programApi.courses.map((course) => course.courseId));
const existingCourseIds = new Set(cohortApi.courses.map((course) => course.courseId));
return coursesApi.orgCourses.filter((course) => !existingCourseIds.has(course.id));
});
@@ -47,8 +47,8 @@
const canGoToNextPage = $derived(currentPage < totalPages);
const emptyMessage = $derived(
searchValue.trim()
? $t('programs.courses.no_matching_courses') || 'No courses match your search.'
: $t('programs.courses.no_available_courses') || 'No available courses to add.'
? $t('cohorts.courses.no_matching_courses') || 'No courses match your search.'
: $t('cohorts.courses.no_available_courses') || 'No available courses to add.'
);
const multiSelectItems = $derived(
paginatedCourses.map((course) => ({
@@ -97,7 +97,7 @@
return;
}
const didAddCourses = await programApi.addCourses(programId, courseIds);
const didAddCourses = await cohortApi.addCourses(cohortId, courseIds);
if (!didAddCourses) {
return;
}
@@ -127,7 +127,7 @@
<Dialog.Root bind:open>
<Dialog.Content class="max-w-md">
<Dialog.Header>
<Dialog.Title>{$t('programs.courses.add_modal_title') || 'Add Course to Program'}</Dialog.Title>
<Dialog.Title>{$t('cohorts.courses.add_modal_title') || 'Add Course to Cohort'}</Dialog.Title>
</Dialog.Header>
<div class="flex flex-col gap-4 py-2">
@@ -139,14 +139,14 @@
isSelected={(id) => selectedCourseIds.has(id)}
onSearchValueChange={handleSearchValueChange}
onToggle={(id) => toggleCourse(id)}
namePrefix="program-course"
namePrefix="cohort-course"
listClass="max-h-60"
searchPlaceholder={$t('programs.courses.search_placeholder') || 'Search courses'}
searchPlaceholder={$t('cohorts.courses.search_placeholder') || 'Search courses'}
bind:searchValue
>
{#snippet headingSnippet()}
<p class="ui:text-sm ui:font-medium">
{$t('programs.courses.select_courses') || $t('programs.courses.select_course') || 'Select course(s)'}
{$t('cohorts.courses.select_courses') || $t('cohorts.courses.select_course') || 'Select course(s)'}
{#if selectedCount > 0}
<span class="ui:text-muted-foreground text-xs">
{$t('audience.selected_count', { count: selectedCount })}
@@ -159,7 +159,7 @@
{#if totalPages > 1}
<div class="flex items-center justify-between gap-2 text-sm">
<span class="ui:text-muted-foreground">
{$t('programs.courses.pagination_status', {
{$t('cohorts.courses.pagination_status', {
page: currentPage,
totalPages
}) || `Page ${currentPage} of ${totalPages}`}
@@ -188,11 +188,11 @@
</div>
<Dialog.Footer>
<Button variant="outline" onclick={closeModal} disabled={programApi.isLoading}>
<Button variant="outline" onclick={closeModal} disabled={cohortApi.isLoading}>
{$t('app.cancel') || 'Cancel'}
</Button>
<Button onclick={handleAdd} loading={programApi.isLoading} disabled={selectedCount === 0 || programApi.isLoading}>
{$t('programs.courses.add') || 'Add Course'}
<Button onclick={handleAdd} loading={cohortApi.isLoading} disabled={selectedCount === 0 || cohortApi.isLoading}>
{$t('cohorts.courses.add') || 'Add Course'}
</Button>
</Dialog.Footer>
</Dialog.Content>
@@ -3,17 +3,17 @@
import * as Item from '@cio/ui/base/item';
import { Separator } from '@cio/ui/base/separator';
import { t } from '$lib/utils/functions/translations';
import type { Program } from '../utils/types';
import type { Cohort } from '../utils/types';
interface Props {
program: Program;
cohort: Cohort;
onclick?: () => void;
}
let { program, onclick }: Props = $props();
let { cohort, onclick }: Props = $props();
const courseCount = $derived(program.courseCount ?? 0);
const studentCount = $derived(program.studentCount ?? 0);
const courseCount = $derived(cohort.courseCount ?? 0);
const studentCount = $derived(cohort.studentCount ?? 0);
</script>
<Item.Root variant="outline" class="group relative max-w-[320px] cursor-pointer p-4!" {onclick}>
@@ -21,7 +21,7 @@
<div class="flex h-full flex-col justify-between gap-5" {...props}>
<Item.Content>
<Item.Title class="line-clamp-2 min-h-14 text-base!">
{program.name}
{cohort.name}
</Item.Title>
<Separator class="my-3" />
@@ -29,13 +29,13 @@
<div class="flex w-full justify-between">
<div class="w-[60%]">
<p class="pl-2 text-xs dark:text-white">
{pluralize($t('programs.card.courses'), courseCount, true)}
{pluralize($t('cohorts.card.courses'), courseCount, true)}
</p>
</div>
<div class="flex flex-col justify-between">
<p class="pl-2 text-xs dark:text-white">
{pluralize($t('programs.card.students'), studentCount, true)}
{pluralize($t('cohorts.card.students'), studentCount, true)}
</p>
</div>
</div>
@@ -7,10 +7,10 @@
import FilterIcon from '@lucide/svelte/icons/filter';
import { t } from '$lib/utils/functions/translations';
import {
DEFAULT_PROGRAM_SORT,
DEFAULT_PROGRAM_SORT_ORDER,
type ProgramSortBy,
type ProgramSortOrder
DEFAULT_COHORT_SORT,
DEFAULT_COHORT_SORT_ORDER,
type CohortSortBy,
type CohortSortOrder
} from '../utils/constants';
interface StatusOption {
@@ -19,13 +19,13 @@
}
interface SortOption {
value: ProgramSortBy;
value: CohortSortBy;
label: string;
}
interface Props {
sortKey?: ProgramSortBy;
selectedOrder?: ProgramSortOrder;
sortKey?: CohortSortBy;
selectedOrder?: CohortSortOrder;
selectedStatuses?: string[];
statusOptions?: StatusOption[];
sortOptions?: SortOption[];
@@ -34,8 +34,8 @@
}
let {
sortKey = $bindable(DEFAULT_PROGRAM_SORT),
selectedOrder = $bindable(DEFAULT_PROGRAM_SORT_ORDER),
sortKey = $bindable(DEFAULT_COHORT_SORT),
selectedOrder = $bindable(DEFAULT_COHORT_SORT_ORDER),
selectedStatuses = [],
statusOptions = [],
sortOptions = [],
@@ -46,7 +46,7 @@
let isOpen = $state(false);
const hasActiveFilters = $derived(
sortKey !== DEFAULT_PROGRAM_SORT || selectedOrder !== DEFAULT_PROGRAM_SORT_ORDER || selectedStatuses.length > 0
sortKey !== DEFAULT_COHORT_SORT || selectedOrder !== DEFAULT_COHORT_SORT_ORDER || selectedStatuses.length > 0
);
function isStatusSelected(status: string) {
@@ -58,7 +58,7 @@
<Popover.Trigger>
{#snippet child({ props })}
<div class="relative">
<IconButton {...props} aria-label={$t('programs.filters.filter')} tooltip={$t('programs.filters.filter')}>
<IconButton {...props} aria-label={$t('cohorts.filters.filter')} tooltip={$t('cohorts.filters.filter')}>
<FilterIcon size={16} />
</IconButton>
{#if hasActiveFilters}
@@ -74,15 +74,15 @@
<Popover.Content align="end" class="w-[360px] p-3">
<div class="space-y-4">
<div class="flex items-center justify-between gap-2">
<p class="text-sm font-semibold">{$t('programs.filters.popover_title')}</p>
<p class="text-sm font-semibold">{$t('cohorts.filters.popover_title')}</p>
<Button variant="link" class="h-auto p-0" onclick={onClearFilters} disabled={!hasActiveFilters}>
{$t('programs.filters.clear_all')}
{$t('cohorts.filters.clear_all')}
</Button>
</div>
<div class="space-y-2">
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">
{$t('programs.filters.sort')}
{$t('cohorts.filters.sort')}
</p>
<div class="flex flex-wrap gap-2">
{#each sortOptions as option (option.value)}
@@ -100,7 +100,7 @@
<div class="space-y-2">
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">
{$t('programs.filters.order')}
{$t('cohorts.filters.order')}
</p>
<ToggleGroup.Root
type="single"
@@ -113,14 +113,14 @@
}
}}
>
<ToggleGroup.Item value="asc">{$t('programs.filters.ascending')}</ToggleGroup.Item>
<ToggleGroup.Item value="desc">{$t('programs.filters.descending')}</ToggleGroup.Item>
<ToggleGroup.Item value="asc">{$t('cohorts.filters.ascending')}</ToggleGroup.Item>
<ToggleGroup.Item value="desc">{$t('cohorts.filters.descending')}</ToggleGroup.Item>
</ToggleGroup.Root>
</div>
<div class="space-y-3">
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">
{$t('programs.filters.status')}
{$t('cohorts.filters.status')}
</p>
<div class="space-y-2">
{#each statusOptions as option (option.value)}
@@ -1,7 +1,7 @@
<script lang="ts">
import { Separator } from '@cio/ui/base/separator';
import * as Sidebar from '@cio/ui/base/sidebar';
import { programApi } from '../api';
import { cohortApi } from '../api';
</script>
<header
@@ -16,7 +16,7 @@
<div class="flex min-w-0 items-center gap-2">
<p class="max-w-2xs truncate text-sm font-medium">
{programApi.program?.name || ''}
{cohortApi.cohort?.name || ''}
</p>
</div>
@@ -5,7 +5,7 @@
import { Textarea } from '@cio/ui/base/textarea';
import { Label } from '@cio/ui/base/label';
import { t } from '$lib/utils/functions/translations';
import { programApi } from '../api';
import { cohortApi } from '../api';
interface Props {
open: boolean;
@@ -18,7 +18,7 @@
async function handleCreate() {
if (!name.trim()) return;
await programApi.createProgram({ name: name.trim(), description: description.trim() || undefined });
await cohortApi.createCohort({ name: name.trim(), description: description.trim() || undefined });
name = '';
description = '';
open = false;
@@ -34,29 +34,29 @@
<Dialog.Root bind:open>
<Dialog.Content class="max-w-md">
<Dialog.Header>
<Dialog.Title>{$t('programs.create_modal.title') || 'Create Program'}</Dialog.Title>
<Dialog.Title>{$t('cohorts.create_modal.title') || 'Create Cohort'}</Dialog.Title>
<Dialog.Description>
{$t('programs.create_modal.description') || 'Add a new certification or compliance program.'}
{$t('cohorts.create_modal.description') || 'Add a new certification or compliance cohort.'}
</Dialog.Description>
</Dialog.Header>
<div class="flex flex-col gap-4 py-2">
<div class="flex flex-col gap-1.5">
<Label for="program-name">{$t('programs.create_modal.name_label') || 'Name'} *</Label>
<Label for="program-name">{$t('cohorts.create_modal.name_label') || 'Name'} *</Label>
<Input
id="program-name"
id="cohort-name"
bind:value={name}
placeholder={$t('programs.create_modal.name_placeholder') || 'e.g. OSHA Safety Certification'}
placeholder={$t('cohorts.create_modal.name_placeholder') || 'e.g. OSHA Safety Certification'}
maxlength={255}
/>
</div>
<div class="flex flex-col gap-1.5">
<Label for="program-description">{$t('programs.create_modal.description_label') || 'Description'}</Label>
<Label for="program-description">{$t('cohorts.create_modal.description_label') || 'Description'}</Label>
<Textarea
id="program-description"
id="cohort-description"
bind:value={description}
placeholder={$t('programs.create_modal.description_placeholder') || 'What is this program about?'}
placeholder={$t('cohorts.create_modal.description_placeholder') || 'What is this cohort about?'}
rows={3}
maxlength={2000}
/>
@@ -65,8 +65,8 @@
<Dialog.Footer>
<Button variant="outline" onclick={handleCancel}>{$t('app.cancel') || 'Cancel'}</Button>
<Button onclick={handleCreate} disabled={!name.trim() || programApi.isLoading}>
{$t('programs.create_modal.submit') || 'Create'}
<Button onclick={handleCreate} disabled={!name.trim() || cohortApi.isLoading}>
{$t('cohorts.create_modal.submit') || 'Create'}
</Button>
</Dialog.Footer>
</Dialog.Content>
@@ -8,26 +8,26 @@
import * as Select from '@cio/ui/base/select';
import { Checkbox } from '@cio/ui/base/checkbox';
import {
PROGRAM_GOAL_DEADLINE_KINDS,
PROGRAM_GOAL_TYPES,
type TCreateProgramGoal,
type TUpdateProgramGoal
} from '@cio/utils/validation/program';
COHORT_GOAL_DEADLINE_KINDS,
COHORT_GOAL_TYPES,
type TCreateCohortGoal,
type TUpdateCohortGoal
} from '@cio/utils/validation/cohort';
import { t } from '$lib/utils/functions/translations';
import { programApi, programGoalApi } from '$features/program/api';
import type { ProgramGoal } from '$features/program/utils/types';
import { cohortApi, cohortGoalApi } from '$features/cohort/api';
import type { CohortGoal } from '$features/cohort/utils/types';
type GoalType = (typeof PROGRAM_GOAL_TYPES)[number];
type DeadlineKind = (typeof PROGRAM_GOAL_DEADLINE_KINDS)[number];
type GoalType = (typeof COHORT_GOAL_TYPES)[number];
type DeadlineKind = (typeof COHORT_GOAL_DEADLINE_KINDS)[number];
interface Props {
open: boolean;
programId: string;
goal: ProgramGoal | null;
cohortId: string;
goal: CohortGoal | null;
onClose: () => void;
}
let { open = $bindable(), programId, goal, onClose }: Props = $props();
let { open = $bindable(), cohortId, goal, onClose }: Props = $props();
let title = $state('');
let description = $state('');
@@ -45,9 +45,9 @@
let formError = $state<string | null>(null);
const isEditing = $derived(goal !== null);
const availableCourses = $derived(programApi.courses);
const availableCourses = $derived(cohortApi.courses);
function resetFromGoal(source: ProgramGoal | null) {
function resetFromGoal(source: CohortGoal | null) {
if (source) {
title = source.title;
description = source.description ?? '';
@@ -105,14 +105,14 @@
.filter((value) => Number.isFinite(value) && value >= 0 && value <= 365);
}
function buildPayload(): TCreateProgramGoal | null {
function buildPayload(): TCreateCohortGoal | null {
if (!title.trim()) {
formError = $t('programs.goals.modal.title_label') + ' is required';
formError = $t('cohorts.goals.modal.title_label') + ' is required';
return null;
}
if (courseIds.length === 0) {
formError = $t('programs.goals.modal.courses_label') + ' is required';
formError = $t('cohorts.goals.modal.courses_label') + ' is required';
return null;
}
@@ -141,10 +141,10 @@
try {
const ok =
isEditing && goal
? await programGoalApi.updateGoal(programId, goal.id, payload as TUpdateProgramGoal)
: await programGoalApi.createGoal(programId, payload);
? await cohortGoalApi.updateGoal(cohortId, goal.id, payload as TUpdateCohortGoal)
: await cohortGoalApi.createGoal(cohortId, payload);
if (ok) onClose();
else formError = programGoalApi.error ?? 'Failed to save';
else formError = cohortGoalApi.error ?? 'Failed to save';
} finally {
isSaving = false;
}
@@ -159,40 +159,40 @@
<Dialog.Content class="max-h-[90vh] w-[96vw] max-w-2xl overflow-y-auto">
<Dialog.Header>
<Dialog.Title>
{isEditing ? $t('programs.goals.modal.edit_title') : $t('programs.goals.modal.create_title')}
{isEditing ? $t('cohorts.goals.modal.edit_title') : $t('cohorts.goals.modal.create_title')}
</Dialog.Title>
</Dialog.Header>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1.5">
<Label for="goal-title">{$t('programs.goals.modal.title_label')} *</Label>
<Label for="goal-title">{$t('cohorts.goals.modal.title_label')} *</Label>
<Input
id="goal-title"
bind:value={title}
maxlength={255}
placeholder={$t('programs.goals.modal.title_placeholder')}
placeholder={$t('cohorts.goals.modal.title_placeholder')}
/>
</div>
<div class="flex flex-col gap-1.5">
<Label for="goal-description">{$t('programs.goals.modal.description_label')}</Label>
<Label for="goal-description">{$t('cohorts.goals.modal.description_label')}</Label>
<Textarea
id="goal-description"
bind:value={description}
rows={2}
maxlength={2000}
placeholder={$t('programs.goals.modal.description_placeholder')}
placeholder={$t('cohorts.goals.modal.description_placeholder')}
/>
</div>
<div class="flex flex-col gap-1.5">
<Label>{$t('programs.goals.modal.type_label')}</Label>
<Label>{$t('cohorts.goals.modal.type_label')}</Label>
<Select.Root type="single" bind:value={type}>
<Select.Trigger>{$t(`programs.goals.type.${type}`)}</Select.Trigger>
<Select.Trigger>{$t(`cohorts.goals.type.${type}`)}</Select.Trigger>
<Select.Content style="z-index: 500 !important">
{#each PROGRAM_GOAL_TYPES as goalType (goalType)}
{#each COHORT_GOAL_TYPES as goalType (goalType)}
<Select.Item value={goalType}>
{$t(`programs.goals.type.${goalType}`)}
{$t(`cohorts.goals.type.${goalType}`)}
</Select.Item>
{/each}
</Select.Content>
@@ -200,9 +200,9 @@
</div>
<div class="flex flex-col gap-1.5">
<Label>{$t('programs.goals.modal.courses_label')} *</Label>
<Label>{$t('cohorts.goals.modal.courses_label')} *</Label>
{#if availableCourses.length === 0}
<p class="ui:text-muted-foreground text-sm">No courses in this program yet.</p>
<p class="ui:text-muted-foreground text-sm">No courses in this cohort yet.</p>
{:else}
<div class="flex flex-col gap-2 rounded-md border p-3">
{#each availableCourses as item (item.courseId)}
@@ -220,7 +220,7 @@
{#if type === 'n_of_m'}
<div class="flex flex-col gap-1.5">
<Label for="goal-required-count">{$t('programs.goals.modal.required_count_label')} *</Label>
<Label for="goal-required-count">{$t('cohorts.goals.modal.required_count_label')} *</Label>
<Input
id="goal-required-count"
type="number"
@@ -237,7 +237,7 @@
{#if type === 'score' || type === 'pass_rate'}
<div class="flex flex-col gap-1.5">
<Label for="goal-score">{$t('programs.goals.modal.score_threshold_label')} *</Label>
<Label for="goal-score">{$t('cohorts.goals.modal.score_threshold_label')} *</Label>
<Input
id="goal-score"
type="number"
@@ -254,7 +254,7 @@
{#if type === 'pass_rate'}
<div class="flex flex-col gap-1.5">
<Label for="goal-team-rate">{$t('programs.goals.modal.team_pass_rate_label')} *</Label>
<Label for="goal-team-rate">{$t('cohorts.goals.modal.team_pass_rate_label')} *</Label>
<Input
id="goal-team-rate"
type="number"
@@ -270,13 +270,13 @@
{/if}
<div class="flex flex-col gap-1.5">
<Label>{$t('programs.goals.modal.deadline_label')}</Label>
<Label>{$t('cohorts.goals.modal.deadline_label')}</Label>
<Select.Root type="single" bind:value={deadlineKind} disabled={type === 'readiness'}>
<Select.Trigger>{$t(`programs.goals.deadline_kind.${deadlineKind}`)}</Select.Trigger>
<Select.Trigger>{$t(`cohorts.goals.deadline_kind.${deadlineKind}`)}</Select.Trigger>
<Select.Content style="z-index: 500 !important">
{#each PROGRAM_GOAL_DEADLINE_KINDS as kind (kind)}
{#each COHORT_GOAL_DEADLINE_KINDS as kind (kind)}
<Select.Item value={kind}>
{$t(`programs.goals.deadline_kind.${kind}`)}
{$t(`cohorts.goals.deadline_kind.${kind}`)}
</Select.Item>
{/each}
</Select.Content>
@@ -285,14 +285,14 @@
{#if deadlineKind === 'absolute'}
<div class="flex flex-col gap-1.5">
<Label for="goal-deadline-date">{$t('programs.goals.modal.deadline_date_label')} *</Label>
<Label for="goal-deadline-date">{$t('cohorts.goals.modal.deadline_date_label')} *</Label>
<Input id="goal-deadline-date" type="date" bind:value={deadlineDate} />
</div>
{/if}
{#if deadlineKind === 'relative_to_join'}
<div class="flex flex-col gap-1.5">
<Label for="goal-relative-days">{$t('programs.goals.modal.relative_days_label')} *</Label>
<Label for="goal-relative-days">{$t('cohorts.goals.modal.relative_days_label')} *</Label>
<Input
id="goal-relative-days"
type="number"
@@ -309,7 +309,7 @@
{#if deadlineKind === 'recurring'}
<div class="flex flex-col gap-1.5">
<Label for="goal-recurring-months">{$t('programs.goals.modal.recurring_months_label')} *</Label>
<Label for="goal-recurring-months">{$t('cohorts.goals.modal.recurring_months_label')} *</Label>
<Input
id="goal-recurring-months"
type="number"
@@ -326,9 +326,9 @@
{#if deadlineKind !== 'none'}
<div class="flex flex-col gap-1.5">
<Label for="goal-reminders">{$t('programs.goals.modal.reminders_label')}</Label>
<Label for="goal-reminders">{$t('cohorts.goals.modal.reminders_label')}</Label>
<Input id="goal-reminders" bind:value={reminderDaysBeforeText} placeholder="7, 3, 1" />
<p class="ui:text-muted-foreground text-xs">{$t('programs.goals.modal.reminders_helper')}</p>
<p class="ui:text-muted-foreground text-xs">{$t('cohorts.goals.modal.reminders_helper')}</p>
</div>
{/if}
@@ -338,10 +338,10 @@
<div class="mt-2 flex justify-end gap-2">
<Button variant="ghost" onclick={onClose} disabled={isSaving}>
{$t('programs.goals.modal.cancel')}
{$t('cohorts.goals.modal.cancel')}
</Button>
<Button variant="secondary" onclick={handleSave} loading={isSaving} disabled={isSaving}>
{$t('programs.goals.modal.save')}
{$t('cohorts.goals.modal.save')}
</Button>
</div>
</div>
@@ -2,30 +2,30 @@
import { onMount } from 'svelte';
import { Badge } from '@cio/ui/base/badge';
import { t } from '$lib/utils/functions/translations';
import { programGoalApi } from '$features/program/api';
import type { ProgramGoal } from '$features/program/utils/types';
import { cohortGoalApi } from '$features/cohort/api';
import type { CohortGoal } from '$features/cohort/utils/types';
interface Props {
programId: string;
cohortId: string;
}
let { programId }: Props = $props();
let { cohortId }: Props = $props();
const goals = $derived(programGoalApi.goalsFor(programId));
const goals = $derived(cohortGoalApi.goalsFor(cohortId));
onMount(() => {
programGoalApi.listGoals(programId);
cohortGoalApi.listGoals(cohortId);
});
function totalLearners(goal: ProgramGoal): number {
function totalLearners(goal: CohortGoal): number {
return Object.values(goal.statusCounts ?? {}).reduce((sum, value) => sum + (value ?? 0), 0);
}
function statusValue(goal: ProgramGoal, status: string): number {
function statusValue(goal: CohortGoal, status: string): number {
return goal.statusCounts?.[status] ?? 0;
}
function onTrackPct(goal: ProgramGoal): number {
function onTrackPct(goal: CohortGoal): number {
const total = totalLearners(goal);
if (total === 0) return 0;
const onTrack =
@@ -36,7 +36,7 @@
{#if goals.length > 0}
<section class="mb-4">
<h3 class="mb-2 text-sm font-semibold">{$t('programs.goals.heading')}</h3>
<h3 class="mb-2 text-sm font-semibold">{$t('cohorts.goals.heading')}</h3>
<div class="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{#each goals as goal (goal.id)}
{@const total = totalLearners(goal)}
@@ -46,27 +46,27 @@
<div class="flex flex-col gap-2 rounded-md border p-3">
<div class="flex items-start justify-between gap-2">
<p class="line-clamp-2 text-sm font-medium">{goal.title}</p>
<Badge variant="outline">{$t(`programs.goals.type.${goal.type}`)}</Badge>
<Badge variant="outline">{$t(`cohorts.goals.type.${goal.type}`)}</Badge>
</div>
<div class="flex items-baseline gap-1">
<span class="text-lg font-semibold">{onTrackPct(goal)}%</span>
<span class="ui:text-muted-foreground text-xs">{$t('programs.goals.tile.on_track')}</span>
<span class="ui:text-muted-foreground text-xs">{$t('cohorts.goals.tile.on_track')}</span>
</div>
<div class="flex flex-wrap gap-3 text-xs">
<span>
<span class="font-medium">{completed}/{total}</span>
{' '}{$t('programs.goals.tile.completed_short')}
{' '}{$t('cohorts.goals.tile.completed_short')}
</span>
{#if atRisk > 0}
<span class="text-amber-700">
<span class="font-medium">{atRisk}</span>
{' '}{$t('programs.goals.tile.at_risk_short')}
{' '}{$t('cohorts.goals.tile.at_risk_short')}
</span>
{/if}
{#if overdue > 0}
<span class="text-red-600">
<span class="font-medium">{overdue}</span>
{' '}{$t('programs.goals.tile.overdue_short')}
{' '}{$t('cohorts.goals.tile.overdue_short')}
</span>
{/if}
</div>
@@ -7,24 +7,24 @@
import PlusIcon from '@lucide/svelte/icons/plus';
import TrashIcon from '@lucide/svelte/icons/trash';
import { t } from '$lib/utils/functions/translations';
import { programGoalApi } from '$features/program/api';
import type { ProgramGoal } from '$features/program/utils/types';
import { cohortGoalApi } from '$features/cohort/api';
import type { CohortGoal } from '$features/cohort/utils/types';
import GoalFormModal from './goal-form-modal.svelte';
interface Props {
programId: string;
cohortId: string;
canManage: boolean;
}
let { programId, canManage }: Props = $props();
let { cohortId, canManage }: Props = $props();
let isModalOpen = $state(false);
let editingGoal = $state<ProgramGoal | null>(null);
let editingGoal = $state<CohortGoal | null>(null);
const goals = $derived(programGoalApi.goalsFor(programId));
const goals = $derived(cohortGoalApi.goalsFor(cohortId));
onMount(() => {
programGoalApi.listGoals(programId);
cohortGoalApi.listGoals(cohortId);
});
function openCreate() {
@@ -32,20 +32,20 @@
isModalOpen = true;
}
function openEdit(goal: ProgramGoal) {
function openEdit(goal: CohortGoal) {
editingGoal = goal;
isModalOpen = true;
}
async function deleteGoal(goal: ProgramGoal) {
const confirmMessage = $t('programs.goals.delete_confirm');
async function deleteGoal(goal: CohortGoal) {
const confirmMessage = $t('cohorts.goals.delete_confirm');
if (!confirm(confirmMessage)) return;
await programGoalApi.deleteGoal(programId, goal.id);
await cohortGoalApi.deleteGoal(cohortId, goal.id);
}
function summarizeDeadline(goal: ProgramGoal) {
if (goal.deadlineKind === 'none') return $t('programs.goals.lms.no_deadline');
function summarizeDeadline(goal: CohortGoal) {
if (goal.deadlineKind === 'none') return $t('cohorts.goals.lms.no_deadline');
if (goal.deadlineKind === 'absolute' && goal.deadlineDate) {
return new Date(goal.deadlineDate).toLocaleDateString();
}
@@ -58,14 +58,14 @@
return `Repeats every ${goal.recurringMonths}mo`;
}
return $t('programs.goals.lms.no_deadline');
return $t('cohorts.goals.lms.no_deadline');
}
function totalLearners(goal: ProgramGoal): number {
function totalLearners(goal: CohortGoal): number {
return Object.values(goal.statusCounts ?? {}).reduce((sum, value) => sum + (value ?? 0), 0);
}
function statusValue(goal: ProgramGoal, status: string): number {
function statusValue(goal: CohortGoal, status: string): number {
return goal.statusCounts?.[status] ?? 0;
}
</script>
@@ -73,21 +73,21 @@
<section>
<div class="mb-3 flex items-start justify-between gap-3">
<div>
<h2 class="text-sm font-semibold">{$t('programs.goals.heading')}</h2>
<p class="ui:text-muted-foreground mt-1 text-xs">{$t('programs.goals.description')}</p>
<h2 class="text-sm font-semibold">{$t('cohorts.goals.heading')}</h2>
<p class="ui:text-muted-foreground mt-1 text-xs">{$t('cohorts.goals.description')}</p>
</div>
{#if canManage}
<Button variant="secondary" onclick={openCreate}>
<PlusIcon size={16} />
{$t('programs.goals.add')}
{$t('cohorts.goals.add')}
</Button>
{/if}
</div>
{#if goals.length === 0}
<div class="rounded-md border p-6 text-center">
<p class="text-sm font-medium">{$t('programs.goals.empty_title')}</p>
<p class="ui:text-muted-foreground mt-1 text-xs">{$t('programs.goals.empty_description')}</p>
<p class="text-sm font-medium">{$t('cohorts.goals.empty_title')}</p>
<p class="ui:text-muted-foreground mt-1 text-xs">{$t('cohorts.goals.empty_description')}</p>
</div>
{:else}
<div class="flex flex-col gap-3">
@@ -101,7 +101,7 @@
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<p class="font-medium">{goal.title}</p>
<Badge variant="outline">{$t(`programs.goals.type.${goal.type}`)}</Badge>
<Badge variant="outline">{$t(`cohorts.goals.type.${goal.type}`)}</Badge>
</div>
{#if goal.description}
<p class="ui:text-muted-foreground mt-1 text-sm">{goal.description}</p>
@@ -124,19 +124,19 @@
<div class="grid grid-cols-2 gap-2 sm:grid-cols-4">
<div class="rounded border p-2 text-center">
<p class="ui:text-muted-foreground text-xs">{$t('programs.goals.status.completed')}</p>
<p class="ui:text-muted-foreground text-xs">{$t('cohorts.goals.status.completed')}</p>
<p class="text-base font-semibold">{completed}/{total}</p>
</div>
<div class="rounded border p-2 text-center">
<p class="ui:text-muted-foreground text-xs">{$t('programs.goals.status.in_progress')}</p>
<p class="ui:text-muted-foreground text-xs">{$t('cohorts.goals.status.in_progress')}</p>
<p class="text-base font-semibold">{statusValue(goal, 'in_progress')}</p>
</div>
<div class="rounded border p-2 text-center {atRisk > 0 ? 'bg-amber-50' : ''}">
<p class="ui:text-muted-foreground text-xs">{$t('programs.goals.status.at_risk')}</p>
<p class="ui:text-muted-foreground text-xs">{$t('cohorts.goals.status.at_risk')}</p>
<p class="text-base font-semibold">{atRisk}</p>
</div>
<div class="rounded border p-2 text-center {overdue > 0 ? 'bg-red-50' : ''}">
<p class="ui:text-muted-foreground text-xs">{$t('programs.goals.status.overdue')}</p>
<p class="ui:text-muted-foreground text-xs">{$t('cohorts.goals.status.overdue')}</p>
<p class="text-base font-semibold">{overdue}</p>
</div>
</div>
@@ -146,4 +146,4 @@
{/if}
</section>
<GoalFormModal bind:open={isModalOpen} {programId} goal={editingGoal} onClose={() => (isModalOpen = false)} />
<GoalFormModal bind:open={isModalOpen} {cohortId} goal={editingGoal} onClose={() => (isModalOpen = false)} />
@@ -2,38 +2,36 @@
import { onMount } from 'svelte';
import { Badge } from '@cio/ui/base/badge';
import { t } from '$lib/utils/functions/translations';
import { programGoalApi } from '$features/program/api';
import { cohortGoalApi } from '$features/cohort/api';
interface Props {
/** When set, only show goals from this program. */
programId?: string;
/** When set, only show goals from this cohort. */
cohortId?: string;
}
let { programId }: Props = $props();
let { cohortId }: Props = $props();
onMount(() => {
programGoalApi.listMyGoals();
cohortGoalApi.listMyGoals();
});
const myGoals = $derived(
programGoalApi.myGoals.filter((assignment) => !programId || assignment.programId === programId)
);
const myGoals = $derived(cohortGoalApi.myGoals.filter((assignment) => !cohortId || assignment.cohortId === cohortId));
function formatDue(assignment: (typeof programGoalApi.myGoals)[number]): string {
if (!assignment.dueDate) return $t('programs.goals.lms.no_deadline');
function formatDue(assignment: (typeof cohortGoalApi.myGoals)[number]): string {
if (!assignment.dueDate) return $t('cohorts.goals.lms.no_deadline');
const dueDate = new Date(assignment.dueDate);
const now = new Date();
const days = Math.ceil((dueDate.getTime() - now.getTime()) / (24 * 60 * 60 * 1000));
if (days < 0) return $t('programs.goals.lms.overdue');
if (days === 0) return $t('programs.goals.lms.due_today');
if (days === 1) return $t('programs.goals.lms.due_tomorrow');
if (days < 0) return $t('cohorts.goals.lms.overdue');
if (days === 0) return $t('cohorts.goals.lms.due_today');
if (days === 1) return $t('cohorts.goals.lms.due_tomorrow');
return $t('programs.goals.lms.due_in_days').replace('{days}', String(days));
return $t('cohorts.goals.lms.due_in_days').replace('{days}', String(days));
}
function progressPct(assignment: (typeof programGoalApi.myGoals)[number]): number {
function progressPct(assignment: (typeof cohortGoalApi.myGoals)[number]): number {
if (assignment.requiredCount <= 0) return 0;
return Math.min(100, Math.round((assignment.completedCount / assignment.requiredCount) * 100));
}
@@ -49,19 +47,19 @@
{#if myGoals.length > 0}
<section class="mb-4">
<h3 class="mb-2 text-sm font-semibold">{$t('programs.goals.lms.heading')}</h3>
<h3 class="mb-2 text-sm font-semibold">{$t('cohorts.goals.lms.heading')}</h3>
<div class="flex flex-col gap-2">
{#each myGoals as assignment (assignment.id)}
<div class="flex flex-col gap-2 rounded-md border p-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="min-w-0">
<p class="font-medium">{assignment.goal.title}</p>
{#if !programId}
<p class="ui:text-muted-foreground text-xs">{assignment.programName}</p>
{#if !cohortId}
<p class="ui:text-muted-foreground text-xs">{assignment.cohortName}</p>
{/if}
</div>
<Badge variant={statusVariant(assignment.status)}>
{$t(`programs.goals.status.${assignment.status}`)}
{$t(`cohorts.goals.status.${assignment.status}`)}
</Badge>
</div>
@@ -14,13 +14,13 @@
import { BulkEmailSection, ExistingStudentsSection, TutorSelectSection } from '$features/people/components';
import type { OrgStudent, Tutor } from '$features/people/utils/types';
import type { OrgTeamMember } from '$lib/utils/types/org';
import { programApi } from '../api';
import { cohortApi } from '../api';
interface Props {
programId: string;
cohortId: string;
}
let { programId }: Props = $props();
let { cohortId }: Props = $props();
let tutors = $state<Tutor[]>([]);
let selectedIds = $state<string[]>([]);
@@ -30,12 +30,12 @@
const addPeopleParam = $derived(new URLSearchParams(page.url.search).get('add'));
const isOpen = $derived(addPeopleParam === 'true');
const selectedTutors = $derived(tutors.filter((tutor) => selectedIds.includes(tutor.id.toString())));
const availableStudents = $derived.by(() => getAvailableStudents(orgApi.audience, programApi.members));
const availableStudents = $derived.by(() => getAvailableStudents(orgApi.audience, cohortApi.members));
const INVITE_MODAL = 'course.navItem.people.invite_modal';
function getTutors(team: OrgTeamMember[]) {
const existingTutorIds = new Set(
programApi.members
cohortApi.members
.filter((member) => Number(member.roleId) === ROLE.TUTOR || Number(member.roleId) === ROLE.ADMIN)
.map((member) => member.profileId)
.filter((profileId): profileId is string => Boolean(profileId))
@@ -52,9 +52,9 @@
}));
}
function getAvailableStudents(students: OrgStudent[], programMembers: typeof programApi.members) {
function getAvailableStudents(students: OrgStudent[], cohortMembers: typeof cohortApi.members) {
const existingStudentIds = new Set(
programMembers
cohortMembers
.filter((member) => Number(member.roleId) === ROLE.STUDENT)
.map((member) => member.profileId)
.filter((profileId): profileId is string => Boolean(profileId))
@@ -133,9 +133,9 @@
return;
}
programApi.success = false;
cohortApi.success = false;
await programApi.addMembers(programId, {
await cohortApi.addMembers(cohortId, {
members: selectedTutors.map((tutor) => ({
profileId: tutor.profileId,
roleId: ROLE.TUTOR,
@@ -144,17 +144,17 @@
}))
});
if (programApi.success) {
if (cohortApi.success) {
closeModal();
}
}
async function assignExistingStudents(profileIds: string[], sendEmail: boolean) {
await programApi.assignExistingStudentsToProgram(programId, { profileIds, sendEmail });
await cohortApi.assignExistingStudentsToCohort(cohortId, { profileIds, sendEmail });
}
async function inviteNewStudents(recipientCsv: string, sendEmail: boolean) {
await programApi.inviteStudentsToProgram(programId, { recipientCsv, sendEmail });
await cohortApi.inviteStudentsToCohort(cohortId, { recipientCsv, sendEmail });
}
$effect(() => {
@@ -162,7 +162,7 @@
});
$effect(() => {
if (!isOpen || !programId) {
if (!isOpen || !cohortId) {
return;
}
@@ -182,7 +182,7 @@
>
<Dialog.Content class="max-h-[80vh] w-[96vw] max-w-3xl! overflow-y-auto">
<Dialog.Header>
<Dialog.Title>{$t('programs.people.invite_modal_title')}</Dialog.Title>
<Dialog.Title>{$t('cohorts.people.invite_modal_title')}</Dialog.Title>
</Dialog.Header>
<UnderlineTabs.Root bind:value={activeTab}>
@@ -205,7 +205,7 @@
/>
<div class="mt-5 flex flex-row-reverse items-center gap-2">
<Button variant="secondary" type="button" onclick={addTutors} loading={programApi.isLoading}>
<Button variant="secondary" type="button" onclick={addTutors} loading={cohortApi.isLoading}>
{$t(`${INVITE_MODAL}.finish`)}
</Button>
</div>
@@ -5,18 +5,18 @@
import * as ResourceListRow from '@cio/ui/custom/resource-list-row';
import { Badge } from '@cio/ui/base/badge';
import { t } from '$lib/utils/functions/translations';
import type { Program } from '../utils/types';
import type { Cohort } from '../utils/types';
interface Props {
program: Program;
cohort: Cohort;
isLMS?: boolean;
href?: string;
}
let { program, isLMS = false, href }: Props = $props();
let { cohort, isLMS = false, href }: Props = $props();
const courseCount = $derived(program.courseCount ?? 0);
const studentCount = $derived(program.studentCount ?? 0);
const courseCount = $derived(cohort.courseCount ?? 0);
const studentCount = $derived(cohort.studentCount ?? 0);
function handleRowClick() {
if (href) {
@@ -24,12 +24,12 @@
return;
}
const nextRoute = isLMS ? `/programs/${program.id}/newsfeed?source=lms` : `/programs/${program.id}/newsfeed`;
const nextRoute = isLMS ? `/cohorts/${cohort.id}/newsfeed?source=lms` : `/cohorts/${cohort.id}/newsfeed`;
goto(resolve(nextRoute, {}));
}
function getStatusVariant(status: Program['status']) {
function getStatusVariant(status: Cohort['status']) {
if (status === 'ACTIVE') {
return 'success';
}
@@ -41,16 +41,16 @@
return 'outline';
}
function getStatusLabelKey(status: Program['status']) {
function getStatusLabelKey(status: Cohort['status']) {
if (status === 'ACTIVE') {
return 'programs.status.active';
return 'cohorts.status.active';
}
if (status === 'INACTIVE') {
return 'programs.status.inactive';
return 'cohorts.status.inactive';
}
return 'programs.status.archived';
return 'cohorts.status.archived';
}
</script>
@@ -63,27 +63,27 @@
>
<div class="ui:flex ui:w-full ui:items-start ui:gap-6">
<div class="ui:min-w-0 ui:flex-1">
<p class="ui:w-full ui:truncate ui:text-base ui:font-semibold">{program.name}</p>
<p class="ui:w-full ui:truncate ui:text-base ui:font-semibold">{cohort.name}</p>
{#if program.description}
<p class="ui:mt-1 ui:text-sm ui:text-muted-foreground">{program.description}</p>
{#if cohort.description}
<p class="ui:mt-1 ui:text-sm ui:text-muted-foreground">{cohort.description}</p>
{/if}
</div>
<div class="ui:flex ui:shrink-0 ui:items-start">
<Badge variant={getStatusVariant(program.status)}>
{$t(getStatusLabelKey(program.status))}
<Badge variant={getStatusVariant(cohort.status)}>
{$t(getStatusLabelKey(cohort.status))}
</Badge>
</div>
<div class="ui:flex ui:shrink-0 ui:flex-col ui:items-start ui:gap-1">
<p class="ui:text-sm ui:text-muted-foreground">
{pluralize(t.get('programs.card.courses'), courseCount, true)}
{pluralize(t.get('cohorts.card.courses'), courseCount, true)}
</p>
{#if !isLMS}
<p class="ui:text-sm ui:text-muted-foreground">
{pluralize(t.get('programs.card.students'), studentCount, true)}
{pluralize(t.get('cohorts.card.students'), studentCount, true)}
</p>
{/if}
</div>
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import type { ProgramNewsfeedItem } from '$features/program/utils/types';
import { programNewsfeedApi, type ProgramNewsfeedCommentsByFeedId } from '$features/program/api';
import type { CohortNewsfeedItem } from '$features/cohort/utils/types';
import { cohortNewsfeedApi, type CohortNewsfeedCommentsByFeedId } from '$features/cohort/api';
import {
getNewsfeedReactionCounts,
getSelectedNewsfeedReactionType,
@@ -12,10 +12,10 @@
import Comments from './comments.svelte';
interface Props {
feed: ProgramNewsfeedItem;
comments?: ProgramNewsfeedCommentsByFeedId;
programId?: string;
editFeed: ProgramNewsfeedItem | null;
feed: CohortNewsfeedItem;
comments?: CohortNewsfeedCommentsByFeedId;
cohortId?: string;
editFeed: CohortNewsfeedItem | null;
author: {
id: string;
username: string;
@@ -30,7 +30,7 @@
deleteComment?: (feedId: string, commentId: string) => void;
addNewComment?: (content: string, feedId: string) => Promise<void> | void;
addNewReaction?: (reactionType: string, feedId: string, authorId: string) => Promise<void> | void;
onPin?: (feedId: ProgramNewsfeedItem['id'], isPinned: ProgramNewsfeedItem['isPinned']) => void;
onPin?: (feedId: CohortNewsfeedItem['id'], isPinned: CohortNewsfeedItem['isPinned']) => void;
isActive?: boolean;
isReacting?: boolean;
}
@@ -38,7 +38,7 @@
let {
feed,
comments,
programId,
cohortId,
editFeed = $bindable(),
author,
reactionAuthorId,
@@ -63,7 +63,7 @@
editFeed = feed;
edit = true;
await tick();
programNewsfeedApi.openNewFeedModal();
cohortNewsfeedApi.openNewFeedModal();
};
const handleReact = (reactionType: string) => addNewReaction(reactionType, feed.id, reactionAuthorId);
@@ -108,7 +108,7 @@
</div>
<Comments
{programId}
{cohortId}
{feed}
{author}
{comments}
@@ -8,15 +8,15 @@
import { isOrgAdmin } from '$lib/utils/store/org';
import { calDateDiff } from '$lib/utils/functions/date';
import { t } from '$lib/utils/functions/translations';
import { programNewsfeedApi } from '$features/program/api';
import { cohortNewsfeedApi } from '$features/cohort/api';
import { Button } from '@cio/ui/base/button';
import { UserAvatar } from '@cio/ui/custom/user-avatar';
import type { ProgramNewsfeedItem } from '$features/program/utils/types';
import type { ProgramNewsfeedCommentsByFeedId } from '$features/program/api';
import type { CohortNewsfeedItem } from '$features/cohort/utils/types';
import type { CohortNewsfeedCommentsByFeedId } from '$features/cohort/api';
interface Props {
programId?: string;
feed: ProgramNewsfeedItem;
cohortId?: string;
feed: CohortNewsfeedItem;
author: {
id: string;
username: string;
@@ -24,12 +24,12 @@
avatarUrl: string;
};
canComment?: boolean;
comments?: ProgramNewsfeedCommentsByFeedId;
comments?: CohortNewsfeedCommentsByFeedId;
onAddComment: (content: string) => Promise<void> | void;
onDeleteComment: (commentId: string | number) => void;
}
let { programId, feed, author, canComment = true, comments, onAddComment, onDeleteComment }: Props = $props();
let { cohortId, feed, author, canComment = true, comments, onAddComment, onDeleteComment }: Props = $props();
let comment = $state('');
let isSubmitting = $state(false);
@@ -45,14 +45,14 @@
const shownCount = $derived(Math.min(visibleCount, loadedCount));
const bootstrap = async () => {
if (!programId || !comments) return;
if (!cohortId || !comments) return;
if (didBootstrap) return;
didBootstrap = true;
isBootstrapping = true;
try {
await programNewsfeedApi.getComments(programId, feed.id);
await cohortNewsfeedApi.getComments(cohortId, feed.id);
} finally {
isBootstrapping = false;
}
@@ -119,18 +119,18 @@
class="justify-start"
>
<UsersIcon size={16} />
<p class="py-2 text-sm">{pluralize($t('programs.newsfeed.comments.comment'), totalCount, true)}</p>
<p class="py-2 text-sm">{pluralize($t('cohorts.newsfeed.comments.comment'), totalCount, true)}</p>
{#if totalCount > shownCount}
<span class="ml-auto text-xs text-gray-600">
{$t('programs.newsfeed.comments.showing_of', { shown: shownCount, total: totalCount })}
{$t('cohorts.newsfeed.comments.showing_of', { shown: shownCount, total: totalCount })}
</span>
{/if}
</Button>
{#if shownCount > 1}
<Button variant="ghost" size="sm" onclick={minimize}>
<span class="ml-auto text-xs text-gray-600">{$t('programs.newsfeed.comments.hide')}</span>
<span class="ml-auto text-xs text-gray-600">{$t('cohorts.newsfeed.comments.hide')}</span>
</Button>
{/if}
</div>
@@ -144,7 +144,7 @@
<div class="h-9 w-9">
<img
src={commentItem.authorAvatarUrl || ''}
alt={$t('programs.newsfeed.user_avatar_alt')}
alt={$t('cohorts.newsfeed.user_avatar_alt')}
class="h-full w-full rounded-full object-cover"
/>
</div>
@@ -166,7 +166,7 @@
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item class="text-red-600" onclick={() => onDeleteComment(commentItem.id)}>
{$t('programs.newsfeed.comments.delete')}
{$t('cohorts.newsfeed.comments.delete')}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
@@ -179,13 +179,13 @@
{#if canComment}
<div class="flex items-center justify-between gap-2">
<UserAvatar src={author.avatarUrl} alt={$t('programs.newsfeed.user_avatar_alt')} class="size-7" />
<UserAvatar src={author.avatarUrl} alt={$t('cohorts.newsfeed.user_avatar_alt')} class="size-7" />
<div class="flex-1">
<input
type="text"
bind:value={comment}
onkeydown={handleAddNewComment}
placeholder={$t('programs.newsfeed.comments.placeholder')}
placeholder={$t('cohorts.newsfeed.comments.placeholder')}
class="w-full rounded-3xl border border-gray-200 bg-transparent p-1 pl-2 text-sm disabled:opacity-50"
disabled={isSubmitting}
required
@@ -197,7 +197,7 @@
class="rounded-full"
loading={isSubmitting}
onclick={handleAddNewComment}
aria-label={$t('programs.newsfeed.comments.submit_aria')}
aria-label={$t('cohorts.newsfeed.comments.submit_aria')}
>
<SendHorizontalIcon size={16} />
</Button>
@@ -19,17 +19,17 @@
<Dialog.Root bind:open={openDeleteModal}>
<Dialog.Content class="w-96">
<Dialog.Header>
<Dialog.Title>{$t('programs.newsfeed.delete_feed_title')}</Dialog.Title>
<Dialog.Title>{$t('cohorts.newsfeed.delete_feed_title')}</Dialog.Title>
</Dialog.Header>
<div>
<p class="text-lg">{$t('programs.newsfeed.delete_feed_description')}</p>
<p class="text-lg">{$t('cohorts.newsfeed.delete_feed_description')}</p>
<div class="mt-5 flex items-center justify-between">
<Button variant="outline" onclick={() => (openDeleteModal = false)}>
{$t('programs.newsfeed.cancel')}
{$t('cohorts.newsfeed.cancel')}
</Button>
<Button variant="outline" onclick={handleDelete}>
{$t('programs.newsfeed.confirm_delete')}
{$t('cohorts.newsfeed.confirm_delete')}
</Button>
</div>
</div>
@@ -5,13 +5,13 @@
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
import { t } from '$lib/utils/functions/translations';
import type { ProgramNewsfeedItem } from '$features/program/utils/types';
import type { CohortNewsfeedItem } from '$features/cohort/utils/types';
import { HTMLRender } from '$features/ui';
interface Props {
feed: ProgramNewsfeedItem;
feed: CohortNewsfeedItem;
canManageFeed?: boolean;
onPin: (feedId: ProgramNewsfeedItem['id'], isPinned: ProgramNewsfeedItem['isPinned']) => void;
onPin: (feedId: CohortNewsfeedItem['id'], isPinned: CohortNewsfeedItem['isPinned']) => void;
onEdit: () => void;
onRequestDelete: () => void;
}
@@ -25,7 +25,7 @@
<div class="h-9 w-9">
<img
src={feed.authorAvatarUrl || ''}
alt={$t('programs.newsfeed.user_avatar_alt')}
alt={$t('cohorts.newsfeed.user_avatar_alt')}
class="h-full w-full rounded-full object-cover"
/>
</div>
@@ -44,11 +44,11 @@
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={() => onPin(feed.id, feed.isPinned)}>
{feed.isPinned ? $t('programs.newsfeed.card.unpin') : $t('programs.newsfeed.card.pin')}
{feed.isPinned ? $t('cohorts.newsfeed.card.unpin') : $t('cohorts.newsfeed.card.pin')}
</DropdownMenu.Item>
<DropdownMenu.Item onclick={onEdit}>{$t('programs.newsfeed.card.edit')}</DropdownMenu.Item>
<DropdownMenu.Item onclick={onEdit}>{$t('cohorts.newsfeed.card.edit')}</DropdownMenu.Item>
<DropdownMenu.Item class="text-red-600" onclick={onRequestDelete}>
{$t('programs.newsfeed.card.delete')}
{$t('cohorts.newsfeed.card.delete')}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
@@ -1,26 +1,26 @@
<script lang="ts">
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$features/ui/snackbar/store';
import type { ProgramNewsfeedItem } from '$features/program/utils/types';
import { getErrorKey } from '$features/program/utils/error';
import { programNewsfeedApi } from '$features/program/api';
import type { CohortNewsfeedItem } from '$features/cohort/utils/types';
import { getErrorKey } from '$features/cohort/utils/error';
import { cohortNewsfeedApi } from '$features/cohort/api';
import { Button } from '@cio/ui/base/button';
import * as Dialog from '@cio/ui/base/dialog';
import { TextEditor } from '$features/ui';
interface Props {
programId?: string;
cohortId?: string;
edit?: boolean;
editFeed: ProgramNewsfeedItem | null;
editFeed: CohortNewsfeedItem | null;
}
let { programId = '', edit = $bindable(false), editFeed = $bindable() }: Props = $props();
let { cohortId = '', edit = $bindable(false), editFeed = $bindable() }: Props = $props();
let isLoading = $state(false);
let newPost = $state('');
$effect(() => {
if (!programNewsfeedApi.isNewFeedModalOpen) {
if (!cohortNewsfeedApi.isNewFeedModalOpen) {
return;
}
@@ -36,7 +36,7 @@
newPost = '';
edit = false;
editFeed = null;
programNewsfeedApi.closeNewFeedModal();
cohortNewsfeedApi.closeNewFeedModal();
};
const onPost = async () => {
@@ -44,27 +44,27 @@
try {
if (edit && editFeed) {
await programNewsfeedApi.update(programId, editFeed.id, { content: newPost ?? '' });
await cohortNewsfeedApi.update(cohortId, editFeed.id, { content: newPost ?? '' });
if (programNewsfeedApi.success) {
if (cohortNewsfeedApi.success) {
resetEditor();
} else {
snackbar.error(getErrorKey(programNewsfeedApi.error, 'update'));
snackbar.error(getErrorKey(cohortNewsfeedApi.error, 'update'));
}
} else {
await programNewsfeedApi.create(programId, {
await cohortNewsfeedApi.create(cohortId, {
content: newPost ?? '',
isPinned: false
});
if (programNewsfeedApi.success) {
if (cohortNewsfeedApi.success) {
resetEditor();
} else {
snackbar.error(getErrorKey(programNewsfeedApi.error, 'create'));
snackbar.error(getErrorKey(cohortNewsfeedApi.error, 'create'));
}
}
} catch {
snackbar.error(getErrorKey(programNewsfeedApi.error, edit ? 'update' : 'create'));
snackbar.error(getErrorKey(cohortNewsfeedApi.error, edit ? 'update' : 'create'));
} finally {
isLoading = false;
}
@@ -72,7 +72,7 @@
</script>
<Dialog.Root
bind:open={programNewsfeedApi.isNewFeedModalOpen}
bind:open={cohortNewsfeedApi.isNewFeedModalOpen}
onOpenChange={(isOpen) => {
if (!isOpen) {
resetEditor();
@@ -82,7 +82,7 @@
<Dialog.Content class="h-fit! w-fit! max-w-none!">
<Dialog.Header>
<Dialog.Title>
{edit ? $t('programs.newsfeed.edit_post') : $t('programs.newsfeed.make_a_post')}
{edit ? $t('cohorts.newsfeed.edit_post') : $t('cohorts.newsfeed.make_a_post')}
</Dialog.Title>
</Dialog.Header>
<section class="max-w-lg">
@@ -93,16 +93,16 @@
newPost = text;
}}
editorClass="max-h-[400px]"
placeholder={$t('programs.newsfeed.editor_placeholder')}
placeholder={$t('cohorts.newsfeed.editor_placeholder')}
/>
{/key}
</section>
<Dialog.Footer>
<Button variant="outline" onclick={resetEditor}>
{$t('programs.newsfeed.cancel')}
{$t('cohorts.newsfeed.cancel')}
</Button>
<Button loading={isLoading} onclick={onPost}>
{$t('programs.newsfeed.post_submit')}
{$t('cohorts.newsfeed.post_submit')}
</Button>
</Dialog.Footer>
</Dialog.Content>
@@ -8,7 +8,7 @@
import { currentOrgPath, isOrgAdmin } from '$lib/utils/store/org';
import { isStudentExperience } from '$lib/utils/store/app';
import { profile } from '$lib/utils/store/user';
import { programApi } from '$features/program/api';
import { cohortApi } from '$features/cohort/api';
import { ROLE } from '@cio/utils/constants';
interface Props {
@@ -19,10 +19,10 @@
let { path, id }: Props = $props();
const currentPath = $derived(path || page.url.pathname);
const isLmsProgramContext = $derived(page.url.searchParams.get('source') === 'lms' || $isStudentExperience);
const programsListPath = $derived(isLmsProgramContext ? '/lms/programs' : `${$currentOrgPath}/programs`);
const isLmsCohortContext = $derived(page.url.searchParams.get('source') === 'lms' || $isStudentExperience);
const cohortsListPath = $derived(isLmsCohortContext ? '/lms/cohorts' : `${$currentOrgPath}/cohorts`);
const currentMemberRole = $derived(programApi.members.find((m) => m.profileId === $profile.id)?.roleId ?? null);
const currentMemberRole = $derived(cohortApi.members.find((m) => m.profileId === $profile.id)?.roleId ?? null);
// Admin + Tutor can manage members; org admins always can
const canManageMembers = $derived(
@@ -36,32 +36,32 @@
[
{
id: 'newsfeed',
title: $t('programs.sidebar.newsfeed') || 'Newsfeed',
url: `/programs/${id}/newsfeed`,
title: $t('cohorts.sidebar.newsfeed') || 'Newsfeed',
url: `/cohorts/${id}/newsfeed`,
isActive: currentPath.endsWith('/newsfeed'),
icon: NewsFeedIcon,
show: () => true
},
{
id: 'courses',
title: $t('programs.sidebar.courses') || 'Courses',
url: `/programs/${id}/courses`,
title: $t('cohorts.sidebar.courses') || 'Courses',
url: `/cohorts/${id}/courses`,
isActive: currentPath.endsWith('/courses'),
icon: CourseIcon,
show: () => true
},
{
id: 'people',
title: $t('programs.sidebar.people') || 'People',
url: `/programs/${id}/people`,
title: $t('cohorts.sidebar.people') || 'People',
url: `/cohorts/${id}/people`,
isActive: currentPath.endsWith('/people'),
icon: PeopleIcon,
show: () => canManageMembers
},
{
id: 'settings',
title: $t('programs.sidebar.settings') || 'Settings',
url: `/programs/${id}/settings`,
title: $t('cohorts.sidebar.settings') || 'Settings',
url: `/cohorts/${id}/settings`,
isActive: currentPath.endsWith('/settings'),
icon: SettingsIcon,
show: () => canManageSettings
@@ -72,8 +72,8 @@
<Sidebar.Group class="pt-0!">
<BackButton
href={resolve(programsListPath, {})}
label={$t(isLmsProgramContext ? 'lms_navigation.programs' : 'org_navigation.programs') || 'Programs'}
href={resolve(cohortsListPath, {})}
label={$t(isLmsCohortContext ? 'lms_navigation.cohorts' : 'org_navigation.cohorts') || 'Cohorts'}
class="px-2! py-2!"
/>
@@ -6,7 +6,7 @@
import { orgs } from '$lib/utils/store/org';
import OrgLogo from '$features/ui/sidebar/org-sidebar/org-logo.svelte';
import Navigation from './program-sidebar-navigation.svelte';
import Navigation from './cohort-sidebar-navigation.svelte';
import SidebarSkeleton from '$features/ui/sidebar/sidebar-skeleton.svelte';
import PoweredBy from '$features/ui/powered-by.svelte';
import { useSidebar } from '@cio/ui/base/sidebar';
@@ -16,10 +16,10 @@
interface Props {
path: string;
id: string;
isProgramReady?: boolean;
isCohortReady?: boolean;
}
let { path, id, isProgramReady = true }: Props = $props();
let { path, id, isCohortReady = true }: Props = $props();
const isOrgLoaded = $derived($orgs.length > 0 && $profile.id);
const sidebar = useSidebar();
@@ -35,7 +35,7 @@
</Sidebar.Header>
<Sidebar.Content>
{#if isProgramReady}
{#if isCohortReady}
<Navigation path={currentPath} {id} />
{:else}
<Sidebar.Group class="pt-0!">
@@ -62,7 +62,7 @@
<Sidebar.Footer>
<PoweredBy
variant="sidebar"
sidebarUtmSource="lms-program-sidebar"
sidebarUtmSource="lms-cohort-sidebar"
showOnlyLogo={!sidebar.open || sidebar.isMobile}
/>
</Sidebar.Footer>
@@ -0,0 +1 @@
export { default as CohortSidebar } from './cohort-sidebar.svelte';
@@ -7,11 +7,11 @@
import { Spinner } from '@cio/ui/base/spinner';
import { GoalIcon } from '@cio/ui/custom/moving-icons';
import { t } from '$lib/utils/functions/translations';
import ProgramList from '../components/list.svelte';
import type { Program } from '../utils/types';
import CohortList from '../components/list.svelte';
import type { Cohort } from '../utils/types';
interface Props {
programs?: Program[];
cohorts?: Cohort[];
isLMS?: boolean;
isLoading?: boolean;
searchValue?: string;
@@ -22,13 +22,13 @@
}
let {
programs = [],
cohorts = [],
isLMS = false,
isLoading = false,
searchValue = $bindable(''),
emptyTitle = $t('programs.empty_title'),
emptyDescription = $t('programs.empty_description'),
searchPlaceholder = $t('programs.search_placeholder'),
emptyTitle = $t('cohorts.empty_title'),
emptyDescription = $t('cohorts.empty_description'),
searchPlaceholder = $t('cohorts.search_placeholder'),
filterControls
}: Props = $props();
</script>
@@ -43,12 +43,12 @@
<div class="flex h-64 items-center justify-center">
<Spinner />
</div>
{:else if !programs.length}
{:else if !cohorts.length}
<Empty title={emptyTitle} description={emptyDescription} icon={GoalIcon} variant="page" />
{:else}
<ResourceListRow.Group class="w-full">
{#each programs as program (program.id)}
<ProgramList {program} {isLMS} />
{#each cohorts as cohort (cohort.id)}
<CohortList {cohort} {isLMS} />
{/each}
</ResourceListRow.Group>
{/if}
@@ -0,0 +1 @@
export { default as CohortsPage } from './cohorts.svelte';
@@ -0,0 +1,4 @@
export type CohortSortBy = 'name' | 'courses' | 'students' | 'status';
export type CohortSortOrder = 'asc' | 'desc';
export const DEFAULT_COHORT_SORT: CohortSortBy = 'name';
export const DEFAULT_COHORT_SORT_ORDER: CohortSortOrder = 'asc';
@@ -0,0 +1,25 @@
import { ErrorCodes } from '@cio/utils/constants';
import { tryParseApiErrorBody } from '$lib/utils/services/api/parse-api-error-body';
const COHORT_ERROR_CODE_SNACKBAR_KEYS: Record<string, string> = {
[ErrorCodes.COHORT_FORBIDDEN]: 'snackbar.cohort.error.code.cohort_forbidden',
[ErrorCodes.COHORT_MEMBER_CHECK_FAILED]: 'snackbar.cohort.error.code.cohort_member_check_failed',
[ErrorCodes.COHORT_NOT_FOUND]: 'snackbar.cohort.error.code.cohort_not_found',
[ErrorCodes.COHORT_CREATE_FAILED]: 'snackbar.cohort.error.code.cohort_create_failed',
[ErrorCodes.COHORT_UPDATE_FAILED]: 'snackbar.cohort.error.code.cohort_update_failed',
[ErrorCodes.COHORT_DELETE_FAILED]: 'snackbar.cohort.error.code.cohort_delete_failed',
[ErrorCodes.COHORT_MEMBER_NOT_FOUND]: 'snackbar.cohort.error.code.cohort_member_not_found',
[ErrorCodes.COHORT_NEWSFEED_NOT_FOUND]: 'snackbar.cohort.error.code.cohort_newsfeed_not_found',
[ErrorCodes.COHORT_NEWSFEED_COMMENT_NOT_FOUND]: 'snackbar.cohort.error.code.cohort_newsfeed_comment_not_found'
};
export function getErrorKey(rawError: string | null, kind: 'create' | 'update'): string {
const parsed = tryParseApiErrorBody(rawError);
const mapped = parsed?.code ? COHORT_ERROR_CODE_SNACKBAR_KEYS[parsed.code] : undefined;
if (mapped) {
return mapped;
}
return kind === 'update' ? 'snackbar.cohort.error.post_update_failed' : 'snackbar.cohort.error.post_create_failed';
}
@@ -0,0 +1,65 @@
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
type CohortListResponse = InferResponseType<typeof classroomio.cohort.$get>;
type CohortListSuccess = Extract<CohortListResponse, { success: true }>;
export type Cohort = CohortListSuccess['data'][number];
type CohortResponse = InferResponseType<(typeof classroomio.cohort)[':cohortId']['$get']>;
type CohortSuccess = Extract<CohortResponse, { success: true }>;
export type CohortDetail = CohortSuccess['data'];
type CohortMembersResponse = InferResponseType<(typeof classroomio.cohort)[':cohortId']['members']['$get']>;
type CohortMembersSuccess = Extract<CohortMembersResponse, { success: true }>;
export type CohortMember = CohortMembersSuccess['data'][number];
type CohortCoursesResponse = InferResponseType<(typeof classroomio.cohort)[':cohortId']['courses']['$get']>;
type CohortCoursesSuccess = Extract<CohortCoursesResponse, { success: true }>;
export type CohortCourse = CohortCoursesSuccess['data'][number];
export type ListCohortNewsfeedRequest = (typeof classroomio.cohort)[':cohortId']['newsfeed']['$get'];
type ListCohortNewsfeedResponse = InferResponseType<ListCohortNewsfeedRequest>;
type ListCohortNewsfeedSuccess = Extract<ListCohortNewsfeedResponse, { success: true }>;
export type CohortNewsfeed = ListCohortNewsfeedSuccess['data'];
export type ListCohortNewsfeed = CohortNewsfeed['items'];
export type CohortNewsfeedItem = ListCohortNewsfeed[number];
export type CreateCohortNewsfeedRequest = (typeof classroomio.cohort)[':cohortId']['newsfeed']['$post'];
export type UpdateCohortNewsfeedRequest = (typeof classroomio.cohort)[':cohortId']['newsfeed'][':feedId']['$put'];
export type ReactToCohortNewsfeedRequest =
(typeof classroomio.cohort)[':cohortId']['newsfeed'][':feedId']['react']['$put'];
export type DeleteCohortNewsfeedRequest = (typeof classroomio.cohort)[':cohortId']['newsfeed'][':feedId']['$delete'];
export type GetCohortNewsfeedCommentsRequest =
(typeof classroomio.cohort)[':cohortId']['newsfeed'][':feedId']['comments']['$get'];
type GetCohortNewsfeedCommentsResponse = InferResponseType<GetCohortNewsfeedCommentsRequest>;
type GetCohortNewsfeedCommentsSuccess = Extract<GetCohortNewsfeedCommentsResponse, { success: true }>;
export type CohortNewsfeedCommentsResponse = GetCohortNewsfeedCommentsSuccess['data'];
export type CohortNewsfeedComment = CohortNewsfeedCommentsResponse[number];
export type CreateCohortNewsfeedCommentRequest =
(typeof classroomio.cohort)[':cohortId']['newsfeed'][':feedId']['comment']['$post'];
export type DeleteCohortNewsfeedCommentRequest =
(typeof classroomio.cohort)[':cohortId']['newsfeed'][':feedId']['comment'][':commentId']['$delete'];
// ─── Goals ───────────────────────────────────────────────────────────────────
export type ListCohortGoalsRequest = (typeof classroomio.cohort)[':cohortId']['goals']['$get'];
type ListCohortGoalsResponse = InferResponseType<ListCohortGoalsRequest>;
type ListCohortGoalsSuccess = Extract<ListCohortGoalsResponse, { success: true }>;
export type CohortGoal = ListCohortGoalsSuccess['data'][number];
export type CohortGoalStatusCounts = CohortGoal['statusCounts'];
export type CreateCohortGoalRequest = (typeof classroomio.cohort)[':cohortId']['goals']['$post'];
export type UpdateCohortGoalRequest = (typeof classroomio.cohort)[':cohortId']['goals'][':goalId']['$put'];
export type DeleteCohortGoalRequest = (typeof classroomio.cohort)[':cohortId']['goals'][':goalId']['$delete'];
export type MyGoalsRequest = typeof classroomio.cohort.my.goals.$get;
type MyGoalsResponse = InferResponseType<MyGoalsRequest>;
type MyGoalsSuccess = Extract<MyGoalsResponse, { success: true }>;
export type MyGoalAssignment = MyGoalsSuccess['data'][number];
export type GoalsOverviewRequest = typeof classroomio.cohort.goals.overview.$get;
type GoalsOverviewResponse = InferResponseType<GoalsOverviewRequest>;
type GoalsOverviewSuccess = Extract<GoalsOverviewResponse, { success: true }>;
export type GoalsOverview = GoalsOverviewSuccess['data'];
export type GoalsOverviewRow = GoalsOverview['goals'][number];
@@ -1,129 +0,0 @@
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
import type { TCreateProgramGoal, TUpdateProgramGoal } from '@cio/utils/validation/program';
import type { GoalsOverviewRow, MyGoalAssignment, ProgramGoal } from '../utils/types';
import { snackbar } from '$features/ui/snackbar/store';
import { t } from '$lib/utils/functions/translations';
import { currentOrg } from '$lib/utils/store/org';
import { get } from 'svelte/store';
class ProgramGoalApi extends BaseApiWithErrors {
goalsByProgramId = $state<Record<string, ProgramGoal[]>>({});
myGoals = $state<MyGoalAssignment[]>([]);
overviewRows = $state<GoalsOverviewRow[]>([]);
loadedProgramId = $state<string | null>(null);
goalsFor(programId: string): ProgramGoal[] {
return this.goalsByProgramId[programId] ?? [];
}
async listGoals(programId: string, force = false) {
if (!force && this.goalsByProgramId[programId] && this.loadedProgramId === programId) return;
await this.execute<(typeof classroomio.program)[':programId']['goals']['$get']>({
requestFn: () => classroomio.program[':programId'].goals.$get({ param: { programId } }),
onSuccess: (res) => {
this.goalsByProgramId = { ...this.goalsByProgramId, [programId]: res.data };
this.loadedProgramId = programId;
},
logContext: 'listProgramGoals'
});
}
async createGoal(programId: string, data: TCreateProgramGoal): Promise<boolean> {
let ok = false;
await this.execute<(typeof classroomio.program)[':programId']['goals']['$post']>({
requestFn: () => classroomio.program[':programId'].goals.$post({ param: { programId }, json: data }),
onSuccess: async () => {
await this.listGoals(programId, true);
snackbar.success(t.get('programs.goals.created') || 'Goal created');
ok = true;
},
logContext: 'createProgramGoal'
});
return ok;
}
async updateGoal(programId: string, goalId: string, data: TUpdateProgramGoal): Promise<boolean> {
let ok = false;
await this.execute<(typeof classroomio.program)[':programId']['goals'][':goalId']['$put']>({
requestFn: () =>
classroomio.program[':programId'].goals[':goalId'].$put({
param: { programId, goalId },
json: data
}),
onSuccess: async () => {
await this.listGoals(programId, true);
snackbar.success(t.get('programs.goals.updated') || 'Goal updated');
ok = true;
},
logContext: 'updateProgramGoal'
});
return ok;
}
async deleteGoal(programId: string, goalId: string): Promise<boolean> {
let ok = false;
await this.execute<(typeof classroomio.program)[':programId']['goals'][':goalId']['$delete']>({
requestFn: () =>
classroomio.program[':programId'].goals[':goalId'].$delete({
param: { programId, goalId }
}),
onSuccess: async () => {
await this.listGoals(programId, true);
snackbar.success(t.get('programs.goals.deleted') || 'Goal deleted');
ok = true;
},
logContext: 'deleteProgramGoal'
});
return ok;
}
async evaluateGoal(programId: string, goalId: string): Promise<boolean> {
let ok = false;
await this.execute<(typeof classroomio.program)[':programId']['goals'][':goalId']['evaluate']['$post']>({
requestFn: () =>
classroomio.program[':programId'].goals[':goalId'].evaluate.$post({
param: { programId, goalId }
}),
onSuccess: async () => {
await this.listGoals(programId, true);
ok = true;
},
logContext: 'evaluateProgramGoal'
});
return ok;
}
async listMyGoals() {
await this.execute<typeof classroomio.program.my.goals.$get>({
requestFn: () => classroomio.program.my.goals.$get({}),
onSuccess: (res) => {
this.myGoals = res.data;
},
logContext: 'listMyGoals'
});
}
async listOverview() {
const org = get(currentOrg);
if (!org.id) return;
await this.execute<typeof classroomio.program.goals.overview.$get>({
requestFn: () => classroomio.program.goals.overview.$get({ query: { organizationId: org.id } }),
onSuccess: (res) => {
this.overviewRows = res.data.goals;
},
logContext: 'listGoalsOverview'
});
}
}
export const programGoalApi = new ProgramGoalApi();
@@ -1,3 +0,0 @@
export { programApi } from './program.svelte';
export { programGoalApi } from './goal.svelte';
export { programNewsfeedApi, type ProgramNewsfeedCommentsByFeedId } from './newsfeed.svelte';
@@ -1,376 +0,0 @@
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
import type { Program, ProgramDetail, ProgramMember, ProgramCourse, ProgramNewsfeed } from '../utils/types';
import type {
TCreateProgram,
TUpdateProgram,
TAddProgramMembers,
TUpdateProgramMember,
TAddCourseToProgram,
TInviteStudentsToProgram,
TAssignExistingStudentsToProgram
} from '@cio/utils/validation/program';
import { currentOrg } from '$lib/utils/store/org';
import { get } from 'svelte/store';
import { isStudentExperience } from '$lib/utils/store/app';
import { snackbar } from '$features/ui/snackbar/store';
import { t } from '$lib/utils/functions/translations';
class ProgramApi extends BaseApiWithErrors {
programs = $state<Program[]>([]);
program = $state<ProgramDetail | null>(null);
members = $state<ProgramMember[]>([]);
courses = $state<ProgramCourse[]>([]);
newsfeed = $state<ProgramNewsfeed | null>(null);
isProgramShellLoading = $state(false);
currentProgramId = $state<string | null>(null);
loadedProgramId = $state<string | null>(null);
loadedMembersProgramId = $state<string | null>(null);
loadedCoursesProgramId = $state<string | null>(null);
loadedCoursesStudentExperience = $state<boolean | null>(null);
loadedNewsfeedProgramId = $state<string | null>(null);
syncProgramCourseCount(programId: string, courseCount: number) {
this.programs = this.programs.map((program) => (program.id === programId ? { ...program, courseCount } : program));
}
async listPrograms() {
const org = get(currentOrg);
if (!org.id) return;
await this.execute<typeof classroomio.program.$get>({
requestFn: () => classroomio.program.$get({ query: { organizationId: org.id } }),
onSuccess: (data) => {
this.programs = data.data;
},
logContext: 'listPrograms'
});
}
async getProgram(programId: string, force = false) {
if (!force && this.loadedProgramId === programId) return;
await this.execute<(typeof classroomio.program)[':programId']['$get']>({
requestFn: () => classroomio.program[':programId'].$get({ param: { programId } }),
onSuccess: (data) => {
this.program = data.data;
this.loadedProgramId = programId;
},
logContext: 'getProgram'
});
}
resetProgramShell(programId?: string) {
this.currentProgramId = programId ?? null;
this.program = null;
this.members = [];
this.courses = [];
this.newsfeed = null;
this.loadedProgramId = null;
this.loadedMembersProgramId = null;
this.loadedCoursesProgramId = null;
this.loadedCoursesStudentExperience = null;
this.loadedNewsfeedProgramId = null;
}
async ensureProgramShell(programId: string, force = false) {
const studentExperience = get(isStudentExperience);
const isShellReady =
this.loadedProgramId === programId &&
this.loadedMembersProgramId === programId &&
this.loadedCoursesProgramId === programId &&
this.loadedCoursesStudentExperience === studentExperience;
if (!force && isShellReady) {
return;
}
const isProgramChanged = this.currentProgramId !== programId;
if (force || isProgramChanged) {
this.resetProgramShell(programId);
}
this.currentProgramId = programId;
this.isProgramShellLoading = true;
try {
await Promise.all([
this.getProgram(programId, force),
this.listMembers(programId, force),
this.listCourses(programId, force)
]);
} finally {
this.isProgramShellLoading = false;
}
}
async createProgram(data: TCreateProgram) {
const org = get(currentOrg);
if (!org.id) return;
await this.execute<typeof classroomio.program.$post>({
requestFn: () =>
classroomio.program.$post({
json: { ...data, organizationId: org.id }
}),
onSuccess: (res) => {
this.programs = [res.data, ...this.programs];
snackbar.success(t.get('programs.create_success') || 'Program created');
},
logContext: 'createProgram'
});
}
async updateProgram(programId: string, data: TUpdateProgram) {
await this.execute<(typeof classroomio.program)[':programId']['$put']>({
requestFn: () => classroomio.program[':programId'].$put({ param: { programId }, json: data }),
onSuccess: (res) => {
this.program = res.data;
this.programs = this.programs.map((p) => (p.id === programId ? { ...p, ...res.data } : p));
snackbar.success(t.get('programs.update_success') || 'Program updated');
},
onError: (result) => {
const message = typeof result === 'string' ? result : result.error;
snackbar.error(message || 'Failed to update program');
},
logContext: 'updateProgram'
});
}
async deleteProgram(programId: string) {
await this.execute<(typeof classroomio.program)[':programId']['$delete']>({
requestFn: () => classroomio.program[':programId'].$delete({ param: { programId } }),
onSuccess: () => {
this.programs = this.programs.filter((p) => p.id !== programId);
snackbar.success(t.get('programs.delete_success') || 'Program deleted');
},
logContext: 'deleteProgram'
});
}
// Members
async listMembers(programId: string, force = false) {
if (!force && this.loadedMembersProgramId === programId) return;
await this.execute<(typeof classroomio.program)[':programId']['members']['$get']>({
requestFn: () => classroomio.program[':programId'].members.$get({ param: { programId } }),
onSuccess: (data) => {
this.members = data.data;
this.loadedMembersProgramId = programId;
},
logContext: 'listProgramMembers'
});
}
async addMembers(programId: string, data: TAddProgramMembers) {
await this.execute<(typeof classroomio.program)[':programId']['members']['$post']>({
requestFn: () => classroomio.program[':programId'].members.$post({ param: { programId }, json: data }),
onSuccess: async () => {
await this.listMembers(programId, true);
snackbar.success(t.get('programs.members_added') || 'Members added');
},
logContext: 'addProgramMembers'
});
}
async updateMember(programId: string, memberId: string, data: TUpdateProgramMember) {
await this.execute<(typeof classroomio.program)[':programId']['members'][':memberId']['$put']>({
requestFn: () =>
classroomio.program[':programId'].members[':memberId'].$put({
param: { programId, memberId },
json: data
}),
onSuccess: (res) => {
this.members = this.members.map((m) => (m.id === memberId ? { ...m, ...res.data } : m));
},
logContext: 'updateProgramMember'
});
}
async removeMember(programId: string, memberId: string) {
await this.execute<(typeof classroomio.program)[':programId']['members'][':memberId']['$delete']>({
requestFn: () =>
classroomio.program[':programId'].members[':memberId'].$delete({
param: { programId, memberId }
}),
onSuccess: () => {
this.members = this.members.filter((m) => m.id !== memberId);
snackbar.success(t.get('programs.member_removed') || 'Member removed');
},
logContext: 'removeProgramMember'
});
}
// Courses
async listCourses(programId: string, force = false) {
const studentExperience = get(isStudentExperience);
if (
!force &&
this.loadedCoursesProgramId === programId &&
this.loadedCoursesStudentExperience === studentExperience
) {
return;
}
await this.execute<(typeof classroomio.program)[':programId']['courses']['$get']>({
requestFn: () => classroomio.program[':programId'].courses.$get({ param: { programId } }),
onSuccess: (data) => {
this.courses = data.data;
this.syncProgramCourseCount(programId, data.data.length);
this.loadedCoursesProgramId = programId;
this.loadedCoursesStudentExperience = studentExperience;
},
logContext: 'listProgramCourses'
});
}
async addCourse(programId: string, data: TAddCourseToProgram) {
await this.execute<(typeof classroomio.program)[':programId']['courses']['$post']>({
requestFn: () => classroomio.program[':programId'].courses.$post({ param: { programId }, json: data }),
onSuccess: async () => {
await this.listCourses(programId, true);
this.syncProgramCourseCount(programId, this.courses.length);
snackbar.success(t.get('programs.course_added') || 'Course added');
},
logContext: 'addCourseToProgram'
});
}
async addCourses(programId: string, courseIds: string[]) {
if (courseIds.length === 0) {
return false;
}
this.isLoading = true;
this.error = null;
this.success = false;
try {
for (const courseId of courseIds) {
const response = await classroomio.program[':programId'].courses.$post({
param: { programId },
json: { courseId }
});
const result = (await response.json()) as { success?: boolean; error?: string };
if (!result.success) {
this.error = result.error ?? 'Failed to add course to program';
return false;
}
}
await this.listCourses(programId, true);
this.syncProgramCourseCount(programId, this.courses.length);
const successMessage =
courseIds.length === 1
? t.get('programs.course_added') || 'Course added'
: t.get('programs.multiple_added') || 'Courses added';
snackbar.success(successMessage);
this.success = true;
return true;
} catch (error) {
console.error('Error in addCoursesToProgram:', error);
this.error = error instanceof Error ? error.message : 'Failed to add courses to program';
return false;
} finally {
this.isLoading = false;
}
}
async removeCourse(programId: string, courseId: string) {
await this.execute<(typeof classroomio.program)[':programId']['courses'][':courseId']['$delete']>({
requestFn: () =>
classroomio.program[':programId'].courses[':courseId'].$delete({
param: { programId, courseId }
}),
onSuccess: () => {
this.courses = this.courses.filter((c) => c.courseId !== courseId);
this.syncProgramCourseCount(programId, this.courses.length);
snackbar.success(t.get('programs.course_removed') || 'Course removed');
},
logContext: 'removeCourseFromProgram'
});
}
// Invitations (program-scoped)
/**
* Invite new students to a program by CSV. Goes through the program-scoped
* endpoint so a Program ADMIN/TUTOR can invite without org-admin rights.
* Returns whether the call succeeded so callers can refresh their lists.
*/
async inviteStudentsToProgram(programId: string, data: TInviteStudentsToProgram): Promise<boolean> {
let ok = false;
await this.execute<(typeof classroomio.program)[':programId']['invite']['$post']>({
requestFn: () => classroomio.program[':programId'].invite.$post({ param: { programId }, json: data }),
onSuccess: async () => {
await this.listMembers(programId, true);
snackbar.success(t.get('programs.invite_sent') || 'Invites sent');
ok = true;
},
logContext: 'inviteStudentsToProgram'
});
return ok;
}
/**
* Assign existing org audience student profiles to this program.
* Same scoping as `inviteStudentsToProgram` — caller only needs program-team
* membership for this program.
*/
async assignExistingStudentsToProgram(programId: string, data: TAssignExistingStudentsToProgram): Promise<boolean> {
let ok = false;
await this.execute<(typeof classroomio.program)[':programId']['invite']['assign']['$post']>({
requestFn: () => classroomio.program[':programId'].invite.assign.$post({ param: { programId }, json: data }),
onSuccess: async () => {
await this.listMembers(programId, true);
snackbar.success(t.get('programs.members_added') || 'Members added');
ok = true;
},
logContext: 'assignExistingStudentsToProgram'
});
return ok;
}
// Newsfeed
async listNewsfeed(programId: string, force = false, cursor?: string) {
if (!force && this.loadedNewsfeedProgramId === programId) return;
await this.execute<(typeof classroomio.program)[':programId']['newsfeed']['$get']>({
requestFn: () =>
classroomio.program[':programId'].newsfeed.$get({
param: { programId },
query: { limit: '10', ...(cursor ? { cursor } : {}) }
}),
onSuccess: (data) => {
this.newsfeed = data.data;
this.loadedNewsfeedProgramId = programId;
},
logContext: 'listProgramNewsfeed'
});
}
// LMS — enrolled programs
async listEnrolledPrograms() {
await this.execute<typeof classroomio.program.enrolled.$get>({
requestFn: () => classroomio.program.enrolled.$get({}),
onSuccess: (data) => {
this.programs = data.data;
},
logContext: 'listEnrolledPrograms'
});
}
}
export const programApi = new ProgramApi();
@@ -1 +0,0 @@
export { default as ProgramSidebar } from './program-sidebar.svelte';
@@ -1 +0,0 @@
export { default as ProgramsPage } from './programs.svelte';
@@ -1,4 +0,0 @@
export type ProgramSortBy = 'name' | 'courses' | 'students' | 'status';
export type ProgramSortOrder = 'asc' | 'desc';
export const DEFAULT_PROGRAM_SORT: ProgramSortBy = 'name';
export const DEFAULT_PROGRAM_SORT_ORDER: ProgramSortOrder = 'asc';
@@ -1,25 +0,0 @@
import { ErrorCodes } from '@cio/utils/constants';
import { tryParseApiErrorBody } from '$lib/utils/services/api/parse-api-error-body';
const PROGRAM_ERROR_CODE_SNACKBAR_KEYS: Record<string, string> = {
[ErrorCodes.PROGRAM_FORBIDDEN]: 'snackbar.program.error.code.program_forbidden',
[ErrorCodes.PROGRAM_MEMBER_CHECK_FAILED]: 'snackbar.program.error.code.program_member_check_failed',
[ErrorCodes.PROGRAM_NOT_FOUND]: 'snackbar.program.error.code.program_not_found',
[ErrorCodes.PROGRAM_CREATE_FAILED]: 'snackbar.program.error.code.program_create_failed',
[ErrorCodes.PROGRAM_UPDATE_FAILED]: 'snackbar.program.error.code.program_update_failed',
[ErrorCodes.PROGRAM_DELETE_FAILED]: 'snackbar.program.error.code.program_delete_failed',
[ErrorCodes.PROGRAM_MEMBER_NOT_FOUND]: 'snackbar.program.error.code.program_member_not_found',
[ErrorCodes.PROGRAM_NEWSFEED_NOT_FOUND]: 'snackbar.program.error.code.program_newsfeed_not_found',
[ErrorCodes.PROGRAM_NEWSFEED_COMMENT_NOT_FOUND]: 'snackbar.program.error.code.program_newsfeed_comment_not_found'
};
export function getErrorKey(rawError: string | null, kind: 'create' | 'update'): string {
const parsed = tryParseApiErrorBody(rawError);
const mapped = parsed?.code ? PROGRAM_ERROR_CODE_SNACKBAR_KEYS[parsed.code] : undefined;
if (mapped) {
return mapped;
}
return kind === 'update' ? 'snackbar.program.error.post_update_failed' : 'snackbar.program.error.post_create_failed';
}
@@ -1,65 +0,0 @@
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
type ProgramListResponse = InferResponseType<typeof classroomio.program.$get>;
type ProgramListSuccess = Extract<ProgramListResponse, { success: true }>;
export type Program = ProgramListSuccess['data'][number];
type ProgramResponse = InferResponseType<(typeof classroomio.program)[':programId']['$get']>;
type ProgramSuccess = Extract<ProgramResponse, { success: true }>;
export type ProgramDetail = ProgramSuccess['data'];
type ProgramMembersResponse = InferResponseType<(typeof classroomio.program)[':programId']['members']['$get']>;
type ProgramMembersSuccess = Extract<ProgramMembersResponse, { success: true }>;
export type ProgramMember = ProgramMembersSuccess['data'][number];
type ProgramCoursesResponse = InferResponseType<(typeof classroomio.program)[':programId']['courses']['$get']>;
type ProgramCoursesSuccess = Extract<ProgramCoursesResponse, { success: true }>;
export type ProgramCourse = ProgramCoursesSuccess['data'][number];
export type ListProgramNewsfeedRequest = (typeof classroomio.program)[':programId']['newsfeed']['$get'];
type ListProgramNewsfeedResponse = InferResponseType<ListProgramNewsfeedRequest>;
type ListProgramNewsfeedSuccess = Extract<ListProgramNewsfeedResponse, { success: true }>;
export type ProgramNewsfeed = ListProgramNewsfeedSuccess['data'];
export type ListProgramNewsfeed = ProgramNewsfeed['items'];
export type ProgramNewsfeedItem = ListProgramNewsfeed[number];
export type CreateProgramNewsfeedRequest = (typeof classroomio.program)[':programId']['newsfeed']['$post'];
export type UpdateProgramNewsfeedRequest = (typeof classroomio.program)[':programId']['newsfeed'][':feedId']['$put'];
export type ReactToProgramNewsfeedRequest =
(typeof classroomio.program)[':programId']['newsfeed'][':feedId']['react']['$put'];
export type DeleteProgramNewsfeedRequest = (typeof classroomio.program)[':programId']['newsfeed'][':feedId']['$delete'];
export type GetProgramNewsfeedCommentsRequest =
(typeof classroomio.program)[':programId']['newsfeed'][':feedId']['comments']['$get'];
type GetProgramNewsfeedCommentsResponse = InferResponseType<GetProgramNewsfeedCommentsRequest>;
type GetProgramNewsfeedCommentsSuccess = Extract<GetProgramNewsfeedCommentsResponse, { success: true }>;
export type ProgramNewsfeedCommentsResponse = GetProgramNewsfeedCommentsSuccess['data'];
export type ProgramNewsfeedComment = ProgramNewsfeedCommentsResponse[number];
export type CreateProgramNewsfeedCommentRequest =
(typeof classroomio.program)[':programId']['newsfeed'][':feedId']['comment']['$post'];
export type DeleteProgramNewsfeedCommentRequest =
(typeof classroomio.program)[':programId']['newsfeed'][':feedId']['comment'][':commentId']['$delete'];
// ─── Goals ───────────────────────────────────────────────────────────────────
export type ListProgramGoalsRequest = (typeof classroomio.program)[':programId']['goals']['$get'];
type ListProgramGoalsResponse = InferResponseType<ListProgramGoalsRequest>;
type ListProgramGoalsSuccess = Extract<ListProgramGoalsResponse, { success: true }>;
export type ProgramGoal = ListProgramGoalsSuccess['data'][number];
export type ProgramGoalStatusCounts = ProgramGoal['statusCounts'];
export type CreateProgramGoalRequest = (typeof classroomio.program)[':programId']['goals']['$post'];
export type UpdateProgramGoalRequest = (typeof classroomio.program)[':programId']['goals'][':goalId']['$put'];
export type DeleteProgramGoalRequest = (typeof classroomio.program)[':programId']['goals'][':goalId']['$delete'];
export type MyGoalsRequest = typeof classroomio.program.my.goals.$get;
type MyGoalsResponse = InferResponseType<MyGoalsRequest>;
type MyGoalsSuccess = Extract<MyGoalsResponse, { success: true }>;
export type MyGoalAssignment = MyGoalsSuccess['data'][number];
export type GoalsOverviewRequest = typeof classroomio.program.goals.overview.$get;
type GoalsOverviewResponse = InferResponseType<GoalsOverviewRequest>;
type GoalsOverviewSuccess = Extract<GoalsOverviewResponse, { success: true }>;
export type GoalsOverview = GoalsOverviewSuccess['data'];
export type GoalsOverviewRow = GoalsOverview['goals'][number];
@@ -14,7 +14,7 @@ const SEARCH_DEBOUNCE_MS = 200;
function emptyResults(): GroupedSearchResults {
return {
course: [],
program: [],
cohort: [],
widget: [],
tag: [],
audience: [],
@@ -137,13 +137,13 @@ function mapSearchResults(data: SearchOrgData, currentOrgPath: string): GroupedS
})
);
results.program = data.programs.map(
(program): SearchResultItem => ({
kind: 'program',
id: program.id,
title: program.name,
subtitle: summarize(program.description),
url: `/programs/${program.id}/courses`
results.cohort = data.cohorts.map(
(cohort): SearchResultItem => ({
kind: 'cohort',
id: cohort.id,
title: cohort.name,
subtitle: summarize(cohort.description),
url: `/cohorts/${cohort.id}/courses`
})
);
@@ -193,13 +193,13 @@ function mapLmsSearchResults(data: SearchLmsData): GroupedSearchResults {
})
);
results.program = data.programs.map(
(program): SearchResultItem => ({
kind: 'program',
id: program.id,
title: program.name,
subtitle: summarize(program.description),
url: `/programs/${program.id}/courses`
results.cohort = data.cohorts.map(
(cohort): SearchResultItem => ({
kind: 'cohort',
id: cohort.id,
title: cohort.name,
subtitle: summarize(cohort.description),
url: `/cohorts/${cohort.id}/courses`
})
);
@@ -37,7 +37,7 @@
const hasQuery = $derived(query.trim().length > 0);
const hasServerResults = $derived(
searchApi.results.course.length > 0 ||
searchApi.results.program.length > 0 ||
searchApi.results.cohort.length > 0 ||
searchApi.results.widget.length > 0 ||
searchApi.results.tag.length > 0 ||
searchApi.results.audience.length > 0
@@ -133,8 +133,8 @@
onSelect={handleSelect}
/>
<SearchResultGroup
heading={$t('app.search.command_palette.groups.programs')}
items={groupItems('program')}
heading={$t('app.search.command_palette.groups.cohorts')}
items={groupItems('cohort')}
fallbackIcon={Goal}
onSelect={handleSelect}
/>
@@ -11,7 +11,7 @@ export type SearchLmsResponse = InferResponseType<SearchLmsRequest>;
export type SearchLmsSuccess = Extract<SearchLmsResponse, { success: true }>;
export type SearchLmsData = SearchLmsSuccess['data'];
export type SearchResultKind = 'course' | 'program' | 'widget' | 'tag' | 'audience' | 'nav' | 'settings';
export type SearchResultKind = 'course' | 'cohort' | 'widget' | 'tag' | 'audience' | 'nav' | 'settings';
export type SearchScope = 'org' | 'lms';
export interface SearchResultItem {
@@ -73,10 +73,10 @@ export const baseNavConfig: NavItemConfig[] = [
matchPattern: '^/lms/explore(/.*)?$'
},
{
titleKey: 'lms_navigation.programs',
path: '/programs',
titleKey: 'lms_navigation.cohorts',
path: '/cohorts',
icon: GoalIcon,
matchPattern: '^/lms/programs(/.*)?$'
matchPattern: '^/lms/cohorts(/.*)?$'
},
{
titleKey: 'lms_navigation.exercise',
@@ -117,10 +117,10 @@ export const baseNavConfig: NavItemConfig[] = [
},
{
group: 'content',
titleKey: 'org_navigation.programs',
path: '/programs',
titleKey: 'org_navigation.cohorts',
path: '/cohorts',
icon: GoalIcon,
matchPattern: '^/org/[^/]+/programs(/.*)?$'
matchPattern: '^/org/[^/]+/cohorts(/.*)?$'
},
{
group: 'content',