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
@@ -1,5 +1,5 @@
export const load = async ({ params }) => {
return {
programId: params.id
cohortId: params.id
};
};
@@ -3,51 +3,51 @@
import * as Sidebar from '@cio/ui/base/sidebar';
import { Empty } from '@cio/ui/custom/empty';
import { Spinner } from '@cio/ui/base/spinner';
import { ProgramSidebar } from '$features/program/components/sidebar';
import ProgramHeader from '$features/program/components/program-header.svelte';
import { programApi } from '$features/program/api';
import { CohortSidebar } from '$features/cohort/components/sidebar';
import CohortHeader from '$features/cohort/components/cohort-header.svelte';
import { cohortApi } from '$features/cohort/api';
import { profile } from '$lib/utils/store/user';
interface Props {
children?: import('svelte').Snippet;
data: {
programId: string;
cohortId: string;
};
}
let { children, data }: Props = $props();
$effect(() => {
if (!data.programId || !$profile.id) return;
programApi.ensureProgramShell(data.programId);
if (!data.cohortId || !$profile.id) return;
cohortApi.ensureCohortShell(data.cohortId);
});
const isProgramReady = $derived.by(() => {
const isCohortReady = $derived.by(() => {
return (
programApi.program?.id === data.programId &&
programApi.loadedMembersProgramId === data.programId &&
programApi.loadedCoursesProgramId === data.programId
cohortApi.cohort?.id === data.cohortId &&
cohortApi.loadedMembersCohortId === data.cohortId &&
cohortApi.loadedCoursesCohortId === data.cohortId
);
});
</script>
<svelte:head>
<title>{programApi.program?.name || 'Program'} - ClassroomIO</title>
<title>{cohortApi.cohort?.name || 'Cohort'} - ClassroomIO</title>
</svelte:head>
<Sidebar.Provider data-sveltekit-preload-data="off">
<ProgramSidebar path={page.url.pathname} id={data.programId} {isProgramReady} />
<CohortSidebar path={page.url.pathname} id={data.cohortId} {isCohortReady} />
<Sidebar.Inset
class="w-[calc(100vw-var(--sidebar-width))] group-data-[collapsible=icon]:w-[calc(100vw-var(--sidebar-width-icon))]"
>
<ProgramHeader />
<CohortHeader />
{#if !isProgramReady}
{#if !isCohortReady}
<div class="mx-auto flex h-[calc(100vh-56px)] w-full items-center justify-center">
<Empty
title="Loading program..."
description="Please wait while we load your program data."
title="Loading cohort..."
description="Please wait while we load your cohort data."
icon={Spinner}
iconClass="h-8 w-8"
variant="page"
@@ -18,9 +18,9 @@
import Plus from '@lucide/svelte/icons/plus';
import Trash2Icon from '@lucide/svelte/icons/trash-2';
import { t } from '$lib/utils/functions/translations';
import { programApi } from '$features/program/api';
import { cohortApi } from '$features/cohort/api';
import type { OrgCourses } from '$features/course/types';
import AddCourseToProgramModal from '$features/program/components/add-course-to-program-modal.svelte';
import AddCourseToCohortModal from '$features/cohort/components/add-course-to-cohort-modal.svelte';
import { browser } from '$app/environment';
import { onMount } from 'svelte';
@@ -31,7 +31,7 @@
let courseToDeleteId = $state<string | null>(null);
let searchValue = $state('');
const courses = $derived(programApi.courses);
const courses = $derived(cohortApi.courses);
const filteredCourses = $derived.by(() => {
const normalizedSearch = searchValue.trim().toLowerCase();
if (!normalizedSearch) {
@@ -72,7 +72,7 @@
async function handleRemoveCourse() {
if (!courseToDeleteId) return;
await programApi.removeCourse(data.programId, courseToDeleteId);
await cohortApi.removeCourse(data.cohortId, courseToDeleteId);
showDeleteModal = false;
courseToDeleteId = null;
}
@@ -93,18 +93,18 @@
<Page.Root class="mx-auto flex w-[90%] px-4 md:max-w-5xl">
<Page.Header>
<Page.HeaderContent class="min-w-0 flex-1">
<Page.Title>{$t('programs.sidebar.courses') || 'Courses'}</Page.Title>
<Page.Title>{$t('cohorts.sidebar.courses') || 'Courses'}</Page.Title>
<Alert.Callout
variant="information"
title={$t('programs.courses.published_access_callout_title')}
description={$t('programs.courses.published_access_callout_description')}
title={$t('cohorts.courses.published_access_callout_title')}
description={$t('cohorts.courses.published_access_callout_description')}
class="mt-3 w-full"
/>
</Page.HeaderContent>
<Page.Action>
<Button onclick={() => (showAddCourseModal = true)}>
<Plus size={16} />
{$t('programs.courses.add') || 'Add Course'}
{$t('cohorts.courses.add') || 'Add Course'}
</Button>
</Page.Action>
</Page.Header>
@@ -126,17 +126,17 @@
{#if filteredCourses.length === 0}
<Empty
title={$t('programs.courses.empty_title') || 'No courses yet'}
title={$t('cohorts.courses.empty_title') || 'No courses yet'}
description={searchValue.trim()
? $t('programs.courses.no_matching_program_courses') || 'No program courses match your search.'
: $t('programs.courses.empty_description') || 'Add courses to this program.'}
? $t('cohorts.courses.no_matching_program_courses') || 'No cohort courses match your search.'
: $t('cohorts.courses.empty_description') || 'Add courses to this cohort.'}
icon={BookOpenIcon}
variant="page"
>
{#if !searchValue.trim()}
<Button onclick={() => (showAddCourseModal = true)}>
<Plus size={16} />
{$t('programs.courses.add') || 'Add Course'}
{$t('cohorts.courses.add') || 'Add Course'}
</Button>
{/if}
</Empty>
@@ -206,5 +206,5 @@
</Page.Body>
</Page.Root>
<AddCourseToProgramModal bind:open={showAddCourseModal} programId={data.programId} />
<DeleteModal bind:open={showDeleteModal} onDelete={handleRemoveCourse} isLoading={programApi.isLoading} />
<AddCourseToCohortModal bind:open={showAddCourseModal} cohortId={data.cohortId} />
<DeleteModal bind:open={showDeleteModal} onDelete={handleRemoveCourse} isLoading={cohortApi.isLoading} />
@@ -5,14 +5,14 @@
import { Button } from '@cio/ui/base/button';
import { Empty } from '@cio/ui/custom/empty';
import { NewsFeedLoader } from '$features/course/components/newsfeed';
import { NewFeedModal, NewsFeedCard } from '$features/program/components/newsfeed';
import MyGoalsWidget from '$features/program/components/goals/my-goals-widget.svelte';
import { programApi, programNewsfeedApi } from '$features/program/api';
import { NewFeedModal, NewsFeedCard } from '$features/cohort/components/newsfeed';
import MyGoalsWidget from '$features/cohort/components/goals/my-goals-widget.svelte';
import { cohortApi, cohortNewsfeedApi } from '$features/cohort/api';
import { t } from '$lib/utils/functions/translations';
import { isOrgAdmin } from '$lib/utils/store/org';
import { profile } from '$lib/utils/store/user';
import { ROLE } from '@cio/utils/constants';
import type { ProgramNewsfeedItem } from '$features/program/utils/types';
import type { CohortNewsfeedItem } from '$features/cohort/utils/types';
import PinIcon from '@lucide/svelte/icons/pin';
import { BookIcon } from '@lucide/svelte';
import { buildNextNewsfeedReaction, type NewsfeedReactionType } from '@cio/ui/custom/newsfeed-reactions';
@@ -20,29 +20,29 @@
interface Props {
data: {
programId: string;
cohortId: string;
};
}
let { data }: Props = $props();
const programId = data.programId;
const cohortId = data.cohortId;
let edit = $state(false);
let editFeed: ProgramNewsfeedItem | null = $state(null);
let editFeed: CohortNewsfeedItem | null = $state(null);
let isListing = $state(true);
let isReactingByFeedId = $state<Record<string, boolean>>({});
const query = new URLSearchParams(page.url.search);
const activeFeedId = query.get('feedId') || '';
const currentProgramMember = $derived.by(
() => programApi.members.find((member) => member.profile?.id === $profile.id) ?? null
const currentCohortMember = $derived.by(
() => cohortApi.members.find((member) => member.profile?.id === $profile.id) ?? null
);
const reactionAuthorId = $derived(currentProgramMember?.id || '');
const reactionAuthorId = $derived(currentCohortMember?.id || '');
const currentProgramRoleId = $derived(currentProgramMember?.roleId || null);
const currentCohortRoleId = $derived(currentCohortMember?.roleId || null);
const author = $derived({
id: $profile.id || '',
@@ -52,13 +52,13 @@
});
const canCreateNewsfeed = $derived(
Boolean($isOrgAdmin) || currentProgramRoleId === ROLE.ADMIN || currentProgramRoleId === ROLE.TUTOR
Boolean($isOrgAdmin) || currentCohortRoleId === ROLE.ADMIN || currentCohortRoleId === ROLE.TUTOR
);
const canManageNewsfeed = $derived(Boolean($isOrgAdmin) || canCreateNewsfeed);
const canComment = $derived(Boolean(reactionAuthorId));
const sortedFeeds = $derived.by(() => {
return [...programNewsfeedApi.feeds].sort((firstFeed, secondFeed) => {
return [...cohortNewsfeedApi.feeds].sort((firstFeed, secondFeed) => {
return Number(Boolean(secondFeed.isPinned)) - Number(Boolean(firstFeed.isPinned));
});
});
@@ -67,20 +67,20 @@
isListing = true;
try {
await programNewsfeedApi.list(programId);
await cohortNewsfeedApi.list(cohortId);
} finally {
isListing = false;
}
};
const deleteComment = async (feedId: string, commentId: string) => {
await programNewsfeedApi.deleteComment(programId, feedId, commentId);
await cohortNewsfeedApi.deleteComment(cohortId, feedId, commentId);
};
const addNewReaction = async (reactionType: NewsfeedReactionType, feedId: string, authorId: string) => {
if (!authorId || isReactingByFeedId[feedId]) return;
const reactedFeed = programNewsfeedApi.feeds.find((feed) => feed.id === feedId);
const reactedFeed = cohortNewsfeedApi.feeds.find((feed) => feed.id === feedId);
if (!reactedFeed) return;
const updatedReaction = buildNextNewsfeedReaction(reactedFeed.reaction ?? undefined, reactionType, authorId);
@@ -88,22 +88,22 @@
isReactingByFeedId = { ...isReactingByFeedId, [feedId]: true };
try {
await programNewsfeedApi.react(programId, feedId, updatedReaction);
await cohortNewsfeedApi.react(cohortId, feedId, updatedReaction);
} finally {
isReactingByFeedId = { ...isReactingByFeedId, [feedId]: false };
}
};
const addNewComment = async (comment: string, feedId: string) => {
await programNewsfeedApi.createComment(programId, feedId, comment, author);
await cohortNewsfeedApi.createComment(cohortId, feedId, comment, author);
};
const onPin = async (feedId: string, isPinned: boolean | null) => {
await programNewsfeedApi.update(programId, feedId, { isPinned: !isPinned });
await cohortNewsfeedApi.update(cohortId, feedId, { isPinned: !isPinned });
};
const deleteFeed = async (feedId: string) => {
await programNewsfeedApi.delete(programId, feedId);
await cohortNewsfeedApi.delete(cohortId, feedId);
};
onMount(async () => {
@@ -115,21 +115,21 @@
<Page.Root class="mx-auto flex w-[90%] px-4 md:max-w-2xl lg:max-w-3xl">
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('programs.sidebar.newsfeed')}</Page.Title>
<Page.Title>{$t('cohorts.sidebar.newsfeed')}</Page.Title>
</Page.HeaderContent>
<Page.Action>
{#if canCreateNewsfeed}
<Button onmouseenter={() => preloadTextEditor()} onclick={() => programNewsfeedApi.openNewFeedModal()}>
{$t('programs.newsfeed.post')}
<Button onmouseenter={() => preloadTextEditor()} onclick={() => cohortNewsfeedApi.openNewFeedModal()}>
{$t('cohorts.newsfeed.post')}
</Button>
{/if}
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
<NewFeedModal {programId} bind:edit bind:editFeed />
<NewFeedModal {cohortId} bind:edit bind:editFeed />
<MyGoalsWidget {programId} />
<MyGoalsWidget {cohortId} />
{#if isListing}
<div class="flex w-full flex-col items-center">
@@ -139,14 +139,14 @@
</div>
{:else if !sortedFeeds.length}
<Empty
title={$t('programs.newsfeed.empty_title')}
description={$t('programs.newsfeed.empty_description')}
title={$t('cohorts.newsfeed.empty_title')}
description={$t('cohorts.newsfeed.empty_description')}
icon={BookIcon}
variant="page"
>
{#if canCreateNewsfeed}
<Button onmouseenter={() => preloadTextEditor()} onclick={() => programNewsfeedApi.openNewFeedModal()}>
{$t('programs.newsfeed.post')}
<Button onmouseenter={() => preloadTextEditor()} onclick={() => cohortNewsfeedApi.openNewFeedModal()}>
{$t('cohorts.newsfeed.post')}
</Button>
{/if}
</Empty>
@@ -155,14 +155,14 @@
{#if feed.isPinned}
<div class="flex items-center gap-2">
<PinIcon size={16} class="filled" />
<p class="text-sm">{$t('programs.newsfeed.pinned')}</p>
<p class="text-sm">{$t('cohorts.newsfeed.pinned')}</p>
</div>
{/if}
<NewsFeedCard
{feed}
comments={programNewsfeedApi.commentsByFeedId[feed.id]}
{programId}
comments={cohortNewsfeedApi.commentsByFeedId[feed.id]}
{cohortId}
{author}
{reactionAuthorId}
{canComment}
@@ -23,21 +23,21 @@
import { ROLE_LABEL } from '$lib/utils/constants/roles';
import { isOrgAdmin } from '$lib/utils/store/org';
import { profile } from '$lib/utils/store/user';
import InviteMembersModal from '$features/program/components/invite-members-modal.svelte';
import GoalsOverviewTiles from '$features/program/components/goals/goals-overview-tiles.svelte';
import { programApi } from '$features/program/api';
import InviteMembersModal from '$features/cohort/components/invite-members-modal.svelte';
import GoalsOverviewTiles from '$features/cohort/components/goals/goals-overview-tiles.svelte';
import { cohortApi } from '$features/cohort/api';
let { data } = $props();
let memberToDelete = $state<(typeof programApi.members)[number] | null>(null);
let memberToDelete = $state<(typeof cohortApi.members)[number] | null>(null);
let isDeleteModalOpen = $state(false);
let filterBy = $state('all');
let searchValue = $state('');
let copiedEmail = $state<string | null>(null);
const members = $derived(sortAndFilterPeople(programApi.members, filterBy));
const members = $derived(sortAndFilterPeople(cohortApi.members, filterBy));
const currentUserRole = $derived.by(() => {
const currentMember = programApi.members.find((member) => member.profileId === $profile.id);
const currentMember = cohortApi.members.find((member) => member.profileId === $profile.id);
return currentMember ? Number(currentMember.roleId) : null;
});
const canManageMembers = $derived.by(
@@ -49,7 +49,7 @@
{ label: $t(ROLE_LABEL[ROLE.STUDENT]), value: `${ROLE.STUDENT}` }
]);
function sortAndFilterPeople(people: typeof programApi.members, activeFilter: string) {
function sortAndFilterPeople(people: typeof cohortApi.members, activeFilter: string) {
return [...(people || [])]
.filter((person) => {
if (activeFilter === 'all') {
@@ -62,7 +62,7 @@
.sort((a, b) => Number(a.roleId) - Number(b.roleId));
}
function filterPeople(query: string, people: typeof programApi.members) {
function filterPeople(query: string, people: typeof cohortApi.members) {
const normalizedQuery = query.toLowerCase();
return people.filter((person) => {
@@ -72,7 +72,7 @@
});
}
function getEmail(person: (typeof programApi.members)[number]) {
function getEmail(person: (typeof cohortApi.members)[number]) {
return person.profile?.email ?? person.email ?? '';
}
@@ -117,10 +117,10 @@
return;
}
programApi.success = false;
cohortApi.success = false;
await programApi.removeMember(data.programId, memberToDelete.id);
if (programApi.success) {
await cohortApi.removeMember(data.cohortId, memberToDelete.id);
if (cohortApi.success) {
isDeleteModalOpen = false;
memberToDelete = null;
}
@@ -134,13 +134,13 @@
<Page.Root class="mx-auto w-[90%] md:max-w-3xl">
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('programs.sidebar.people')}</Page.Title>
<Page.Title>{$t('cohorts.sidebar.people')}</Page.Title>
</Page.HeaderContent>
<Page.Action>
{#if canManageMembers}
<Button onclick={openInviteModal}>
<PlusIcon size={16} />
{$t('programs.people.invite')}
{$t('cohorts.people.invite')}
</Button>
{/if}
</Page.Action>
@@ -148,7 +148,7 @@
<Page.Body>
{#snippet child()}
<GoalsOverviewTiles programId={data.programId} />
<GoalsOverviewTiles cohortId={data.cohortId} />
<section class="space-y-2">
<div class="flex flex-col items-center justify-end gap-2 md:flex-row">
@@ -283,7 +283,7 @@
</Page.Body>
</Page.Root>
<InviteMembersModal programId={data.programId} />
<InviteMembersModal cohortId={data.cohortId} />
<Dialog.Root bind:open={isDeleteModalOpen}>
<Dialog.Content class="w-96 pt-3">
@@ -300,7 +300,7 @@
<Button variant="outline" onclick={() => (isDeleteModalOpen = false)}>
{$t('course.navItem.people.delete_confirmation.no')}
</Button>
<Button variant="outline" onclick={deletePerson} loading={programApi.isLoading}>
<Button variant="outline" onclick={deletePerson} loading={cohortApi.isLoading}>
{$t('course.navItem.people.delete_confirmation.yes')}
</Button>
</div>
@@ -7,16 +7,16 @@
import * as Select from '@cio/ui/base/select';
import { Separator } from '@cio/ui/base/separator';
import { t } from '$lib/utils/functions/translations';
import { programApi } from '$features/program/api';
import { cohortApi } from '$features/cohort/api';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { currentOrgPath, isOrgAdmin } from '$lib/utils/store/org';
import { profile } from '$lib/utils/store/user';
import { ROLE } from '@cio/utils/constants';
import GoalsSection from '$features/program/components/goals/goals-section.svelte';
import GoalsSection from '$features/cohort/components/goals/goals-section.svelte';
let { data } = $props();
const programId = data.programId as string;
const cohortId = data.cohortId as string;
let name = $state('');
let description = $state('');
@@ -24,27 +24,27 @@
let isSaving = $state(false);
$effect(() => {
const p = programApi.program;
if (p) {
name = p.name;
description = p.description ?? '';
status = (p.status as 'ACTIVE' | 'INACTIVE' | 'ARCHIVED') ?? 'ACTIVE';
const item = cohortApi.cohort;
if (item) {
name = item.name;
description = item.description ?? '';
status = (item.status as 'ACTIVE' | 'INACTIVE' | 'ARCHIVED') ?? 'ACTIVE';
}
});
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);
const canManageGoals = $derived(
Boolean($isOrgAdmin) || currentMemberRole === ROLE.ADMIN || currentMemberRole === ROLE.TUTOR
);
const statusOptions = [
{ value: 'ACTIVE', label: $t('programs.status.active') || 'Active' },
{ value: 'INACTIVE', label: $t('programs.status.inactive') || 'Inactive' },
{ value: 'ARCHIVED', label: $t('programs.status.archived') || 'Archived' }
{ value: 'ACTIVE', label: $t('cohorts.status.active') || 'Active' },
{ value: 'INACTIVE', label: $t('cohorts.status.inactive') || 'Inactive' },
{ value: 'ARCHIVED', label: $t('cohorts.status.archived') || 'Archived' }
];
const hasUnsavedChanges = $derived.by(() => {
const currentProgram = programApi.program;
const currentProgram = cohortApi.program;
if (!currentProgram) {
return false;
}
@@ -60,7 +60,7 @@
isSaving = true;
try {
await programApi.updateProgram(programId, {
await cohortApi.updateCohort(cohortId, {
name: name.trim() || undefined,
description: description.trim() || null,
status
@@ -73,24 +73,24 @@
async function handleDelete() {
if (
!confirm(
$t('programs.settings.confirm_delete') || 'Are you sure you want to delete this program? This cannot be undone.'
$t('cohorts.settings.confirm_delete') || 'Are you sure you want to delete this cohort? This cannot be undone.'
)
) {
return;
}
await programApi.deleteProgram(programId);
goto(resolve(`${$currentOrgPath}/programs`, {}));
await cohortApi.deleteCohort(cohortId);
goto(resolve(`${$currentOrgPath}/cohorts`, {}));
}
</script>
<Page.Root class="mx-auto flex w-[90%] px-4 md:max-w-2xl lg:max-w-3xl">
<Page.Header class="sticky top-13 z-10 bg-white">
<Page.HeaderContent>
<Page.Title>{$t('programs.sidebar.settings') || 'Settings'}</Page.Title>
<Page.Title>{$t('cohorts.sidebar.settings') || 'Settings'}</Page.Title>
</Page.HeaderContent>
<Page.Action>
<Button variant="secondary" loading={isSaving} disabled={isSaving || !hasUnsavedChanges} onclick={handleSave}>
{$t('programs.settings.save') || 'Save Changes'}
{$t('cohorts.settings.save') || 'Save Changes'}
</Button>
</Page.Action>
</Page.Header>
@@ -99,20 +99,20 @@
<div class="w-full px-4 py-6">
<div class="flex flex-col gap-6">
<section>
<h2 class="mb-4 text-sm font-semibold">{$t('programs.settings.general') || 'General'}</h2>
<h2 class="mb-4 text-sm font-semibold">{$t('cohorts.settings.general') || 'General'}</h2>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1.5">
<Label for="prog-name">{$t('programs.settings.name_label') || 'Name'} *</Label>
<Label for="prog-name">{$t('cohorts.settings.name_label') || 'Name'} *</Label>
<Input id="prog-name" bind:value={name} maxlength={255} />
</div>
<div class="flex flex-col gap-1.5">
<Label for="prog-desc">{$t('programs.settings.description_label') || 'Description'}</Label>
<Label for="prog-desc">{$t('cohorts.settings.description_label') || 'Description'}</Label>
<Textarea id="prog-desc" bind:value={description} rows={4} maxlength={2000} />
</div>
<div class="flex flex-col gap-1.5">
<Label>{$t('programs.settings.status_label') || 'Status'}</Label>
<Label>{$t('cohorts.settings.status_label') || 'Status'}</Label>
<Select.Root type="single" bind:value={status}>
<Select.Trigger class="w-48">
{statusOptions.find((o) => o.value === status)?.label}
@@ -129,20 +129,20 @@
<Separator />
<GoalsSection {programId} canManage={canManageGoals} />
<GoalsSection {cohortId} canManage={canManageGoals} />
<Separator />
<section>
<h2 class="mb-1 text-sm font-semibold text-red-600">
{$t('programs.settings.danger_zone') || 'Danger Zone'}
{$t('cohorts.settings.danger_zone') || 'Danger Zone'}
</h2>
<p class="text-muted-foreground mb-4 text-sm">
{$t('programs.settings.delete_description') ||
'Once deleted, this program and all its data cannot be recovered.'}
{$t('cohorts.settings.delete_description') ||
'Once deleted, this cohort and all its data cannot be recovered.'}
</p>
<Button variant="destructive" onclick={handleDelete} disabled={programApi.isLoading}>
{$t('programs.settings.delete') || 'Delete Program'}
<Button variant="destructive" onclick={handleDelete} disabled={cohortApi.isLoading}>
{$t('cohorts.settings.delete') || 'Delete Program'}
</Button>
</section>
</div>
@@ -0,0 +1,52 @@
<script lang="ts">
import * as Page from '@cio/ui/base/page';
import { t } from '$lib/utils/functions/translations';
import { cohortApi } from '$features/cohort/api';
import { profile } from '$lib/utils/store/user';
import { CohortsPage } from '$features/cohort/pages';
let searchValue = $state('');
$effect(() => {
if ($profile.id) {
cohortApi.listEnrolledCohorts();
}
});
const cohorts = $derived(cohortApi.cohorts);
const filteredCohorts = $derived.by(() => {
const normalizedSearch = searchValue.trim().toLowerCase();
return cohorts.filter((cohort) => {
if (!normalizedSearch) {
return true;
}
return [cohort.name, cohort.description].some((value) => value?.toLowerCase().includes(normalizedSearch));
});
});
</script>
<svelte:head>
<title>{$t('lms_navigation.cohorts') || 'Cohorts'} - ClassroomIO</title>
</svelte:head>
<Page.Root class="w-full">
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('lms_navigation.cohorts') || 'Cohorts'}</Page.Title>
</Page.HeaderContent>
</Page.Header>
<Page.Body>
{#snippet child()}
<CohortsPage
cohorts={filteredCohorts}
isLMS={true}
isLoading={cohortApi.isLoading && cohorts.length === 0}
bind:searchValue
emptyTitle={$t('cohorts.lms.empty_title') || 'No cohorts yet'}
emptyDescription={$t('cohorts.lms.empty_description')}
/>
{/snippet}
</Page.Body>
</Page.Root>
@@ -1,52 +0,0 @@
<script lang="ts">
import * as Page from '@cio/ui/base/page';
import { t } from '$lib/utils/functions/translations';
import { programApi } from '$features/program/api';
import { profile } from '$lib/utils/store/user';
import { ProgramsPage } from '$features/program/pages';
let searchValue = $state('');
$effect(() => {
if ($profile.id) {
programApi.listEnrolledPrograms();
}
});
const programs = $derived(programApi.programs);
const filteredPrograms = $derived.by(() => {
const normalizedSearch = searchValue.trim().toLowerCase();
return programs.filter((program) => {
if (!normalizedSearch) {
return true;
}
return [program.name, program.description].some((value) => value?.toLowerCase().includes(normalizedSearch));
});
});
</script>
<svelte:head>
<title>{$t('lms_navigation.programs') || 'Programs'} - ClassroomIO</title>
</svelte:head>
<Page.Root class="w-full">
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('lms_navigation.programs') || 'Programs'}</Page.Title>
</Page.HeaderContent>
</Page.Header>
<Page.Body>
{#snippet child()}
<ProgramsPage
programs={filteredPrograms}
isLMS={true}
isLoading={programApi.isLoading && programs.length === 0}
bind:searchValue
emptyTitle={$t('programs.lms.empty_title') || 'No programs yet'}
emptyDescription={$t('programs.lms.empty_description')}
/>
{/snippet}
</Page.Body>
</Page.Root>
@@ -4,8 +4,8 @@ import { safeServerApi } from '$lib/utils/services/api/server';
type GetOrganizationCoursesRequest = typeof classroomio.organization.courses.$get;
type GetOrganizationCoursesSuccess = Extract<InferResponseType<GetOrganizationCoursesRequest>, { success: true }>;
type GetProgramsRequest = typeof classroomio.program.$get;
type GetProgramsSuccess = Extract<InferResponseType<GetProgramsRequest>, { success: true }>;
type GetCohortsRequest = typeof classroomio.cohort.$get;
type GetCohortsSuccess = Extract<InferResponseType<GetCohortsRequest>, { success: true }>;
export const load = async ({ parent, cookies }) => {
const { orgId } = await parent();
@@ -13,21 +13,21 @@ export const load = async ({ parent, cookies }) => {
if (!orgId) {
return {
courses: [],
programs: []
cohorts: []
};
}
const [coursesResult, programsResult] = await Promise.all([
const [coursesResult, cohortsResult] = await Promise.all([
safeServerApi<GetOrganizationCoursesSuccess>(() =>
classroomio.organization.courses.$get({ query: {} }, getApiHeaders(cookies, orgId))
),
safeServerApi<GetProgramsSuccess>(() =>
classroomio.program.$get({ query: { organizationId: orgId } }, getApiHeaders(cookies, orgId))
safeServerApi<GetCohortsSuccess>(() =>
classroomio.cohort.$get({ query: { organizationId: orgId } }, getApiHeaders(cookies, orgId))
)
]);
return {
courses: coursesResult.ok ? coursesResult.body.data : [],
programs: programsResult.ok ? programsResult.body.data : []
cohorts: cohortsResult.ok ? cohortsResult.body.data : []
};
};
@@ -31,7 +31,7 @@
</Page.Header>
<Page.Body>
{#snippet child()}
<ImportAudiencePage courses={data.courses} programs={data.programs} />
<ImportAudiencePage courses={data.courses} cohorts={data.cohorts} />
{/snippet}
</Page.Body>
</Page.Root>
@@ -0,0 +1,21 @@
import { classroomio, getApiHeaders, type InferResponseType } from '$lib/utils/services/api';
import { safeServerApi } from '$lib/utils/services/api/server';
type GetCohortsRequest = typeof classroomio.cohort.$get;
type GetCohortsSuccess = Extract<InferResponseType<GetCohortsRequest>, { success: true }>;
export const load = async ({ parent, locals, cookies }) => {
const { orgId } = await parent();
if (!orgId || !locals.user?.id) {
return { cohorts: [] };
}
const result = await safeServerApi<GetCohortsSuccess>(() =>
classroomio.cohort.$get({ query: { organizationId: orgId } }, getApiHeaders(cookies, orgId))
);
return {
cohorts: result.ok ? result.body.data : []
};
};
@@ -0,0 +1,156 @@
<script lang="ts">
import * as Page from '@cio/ui/base/page';
import { Button } from '@cio/ui/base/button';
import { Empty } from '@cio/ui/custom/empty';
import { GoalIcon } from '@cio/ui/custom/moving-icons';
import Plus from '@lucide/svelte/icons/plus';
import { t } from '$lib/utils/functions/translations';
import { cohortApi } from '$features/cohort/api';
import { CohortsPage } from '$features/cohort/pages';
import type { Cohort } from '$features/cohort/utils/types';
import CreateCohortModal from '$features/cohort/components/create-cohort-modal.svelte';
import {
DEFAULT_COHORT_SORT,
DEFAULT_COHORT_SORT_ORDER,
type CohortSortBy,
type CohortSortOrder
} from '$features/cohort/utils/constants';
import CohortFilterPopover from '$features/cohort/components/cohort-filter-popover.svelte';
import { SvelteSet } from 'svelte/reactivity';
let { data } = $props();
let showCreateModal = $state(false);
let searchValue = $state('');
let sortKey = $state<CohortSortBy>(DEFAULT_COHORT_SORT);
let selectedOrder = $state<CohortSortOrder>(DEFAULT_COHORT_SORT_ORDER);
let selectedStatuses = $state<string[]>([]);
$effect(() => {
if (data.cohorts) {
cohortApi.cohorts = data.cohorts;
}
});
const statusOptions = $derived([
{ value: 'ACTIVE' as const, label: $t('cohorts.status.active') || 'Active' },
{ value: 'INACTIVE' as const, label: $t('cohorts.status.inactive') || 'Inactive' },
{ value: 'ARCHIVED' as const, label: $t('cohorts.status.archived') || 'Archived' }
]);
const sortOptions = $derived([
{ value: 'name' as const, label: $t('cohorts.filters.sort_name') || 'Name' },
{ value: 'courses' as const, label: $t('cohorts.filters.sort_courses') || 'Courses' },
{ value: 'students' as const, label: $t('cohorts.filters.sort_students') || 'Students' },
{ value: 'status' as const, label: $t('cohorts.filters.sort_status') || 'Status' }
]);
const filteredCohorts = $derived.by(() => {
const normalizedSearch = searchValue.trim().toLowerCase();
const sourceCohorts = cohortApi.cohorts.filter((item: Cohort) => {
const matchesSearch =
!normalizedSearch ||
[item.name, item.description].some((value) => value?.toLowerCase().includes(normalizedSearch));
const matchesStatus = selectedStatuses.length === 0 || selectedStatuses.includes(item.status);
return matchesSearch && matchesStatus;
});
const sortedCohorts = [...sourceCohorts];
return sortedCohorts.sort((firstCohort, secondCohort) => {
let comparison = 0;
if (sortKey === 'name') {
comparison = firstCohort.name.localeCompare(secondCohort.name);
} else if (sortKey === 'courses') {
comparison = firstCohort.courseCount - secondCohort.courseCount;
} else if (sortKey === 'students') {
comparison = firstCohort.studentCount - secondCohort.studentCount;
} else if (sortKey === 'status') {
comparison = firstCohort.status.localeCompare(secondCohort.status);
}
return selectedOrder === 'asc' ? comparison : comparison * -1;
});
});
function toggleStatus(status: string, checked: boolean) {
const nextStatuses = new SvelteSet(selectedStatuses);
if (checked) {
nextStatuses.add(status);
} else {
nextStatuses.delete(status);
}
selectedStatuses = Array.from(nextStatuses);
}
function clearFilters() {
sortKey = DEFAULT_COHORT_SORT;
selectedOrder = DEFAULT_COHORT_SORT_ORDER;
selectedStatuses = [];
}
</script>
<svelte:head>
<title>{$t('cohorts.page_title') || 'Cohorts'} - ClassroomIO</title>
</svelte:head>
<Page.Root class="w-full">
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('cohorts.heading') || 'Cohorts'}</Page.Title>
<Page.Subtitle>
{$t('cohorts.description') || 'Give a group of people access to multiple courses, like a class'}
</Page.Subtitle>
</Page.HeaderContent>
<Page.Action>
<Button onclick={() => (showCreateModal = true)}>
<Plus size={16} />
{$t('cohorts.create') || 'New Program'}
</Button>
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
{#if !cohortApi.isLoading && filteredCohorts.length === 0 && !searchValue.trim() && selectedStatuses.length === 0}
<Empty
title={$t('cohorts.empty_title') || 'No cohorts yet'}
description={$t('cohorts.empty_description') || 'Create your first cohort to get started.'}
icon={GoalIcon}
variant="page"
>
<Button onclick={() => (showCreateModal = true)}>
<Plus size={16} />
{$t('cohorts.create') || 'New Program'}
</Button>
</Empty>
{:else}
<CohortsPage
cohorts={filteredCohorts}
isLoading={cohortApi.isLoading && filteredCohorts.length === 0}
bind:searchValue
emptyTitle={$t('cohorts.empty_title') || 'No cohorts yet'}
emptyDescription={searchValue.trim() || selectedStatuses.length > 0
? $t('cohorts.no_matching_cohorts') || 'No cohorts match your filters.'
: $t('cohorts.empty_description') || 'Create your first cohort to get started.'}
>
{#snippet filterControls()}
<CohortFilterPopover
bind:sortKey
bind:selectedOrder
{selectedStatuses}
{statusOptions}
{sortOptions}
onToggleStatus={toggleStatus}
onClearFilters={clearFilters}
/>
{/snippet}
</CohortsPage>
{/if}
{/snippet}
</Page.Body>
</Page.Root>
<CreateCohortModal bind:open={showCreateModal} />
@@ -1,21 +0,0 @@
import { classroomio, getApiHeaders, type InferResponseType } from '$lib/utils/services/api';
import { safeServerApi } from '$lib/utils/services/api/server';
type GetProgramsRequest = typeof classroomio.program.$get;
type GetProgramsSuccess = Extract<InferResponseType<GetProgramsRequest>, { success: true }>;
export const load = async ({ parent, locals, cookies }) => {
const { orgId } = await parent();
if (!orgId || !locals.user?.id) {
return { programs: [] };
}
const result = await safeServerApi<GetProgramsSuccess>(() =>
classroomio.program.$get({ query: { organizationId: orgId } }, getApiHeaders(cookies, orgId))
);
return {
programs: result.ok ? result.body.data : []
};
};
@@ -1,156 +0,0 @@
<script lang="ts">
import * as Page from '@cio/ui/base/page';
import { Button } from '@cio/ui/base/button';
import { Empty } from '@cio/ui/custom/empty';
import { GoalIcon } from '@cio/ui/custom/moving-icons';
import Plus from '@lucide/svelte/icons/plus';
import { t } from '$lib/utils/functions/translations';
import { programApi } from '$features/program/api';
import { ProgramsPage } from '$features/program/pages';
import type { Program } from '$features/program/utils/types';
import CreateProgramModal from '$features/program/components/create-program-modal.svelte';
import {
DEFAULT_PROGRAM_SORT,
DEFAULT_PROGRAM_SORT_ORDER,
type ProgramSortBy,
type ProgramSortOrder
} from '$features/program/utils/constants';
import ProgramFilterPopover from '$features/program/components/program-filter-popover.svelte';
import { SvelteSet } from 'svelte/reactivity';
let { data } = $props();
let showCreateModal = $state(false);
let searchValue = $state('');
let sortKey = $state<ProgramSortBy>(DEFAULT_PROGRAM_SORT);
let selectedOrder = $state<ProgramSortOrder>(DEFAULT_PROGRAM_SORT_ORDER);
let selectedStatuses = $state<string[]>([]);
$effect(() => {
if (data.programs) {
programApi.programs = data.programs;
}
});
const statusOptions = $derived([
{ value: 'ACTIVE' as const, label: $t('programs.status.active') || 'Active' },
{ value: 'INACTIVE' as const, label: $t('programs.status.inactive') || 'Inactive' },
{ value: 'ARCHIVED' as const, label: $t('programs.status.archived') || 'Archived' }
]);
const sortOptions = $derived([
{ value: 'name' as const, label: $t('programs.filters.sort_name') || 'Name' },
{ value: 'courses' as const, label: $t('programs.filters.sort_courses') || 'Courses' },
{ value: 'students' as const, label: $t('programs.filters.sort_students') || 'Students' },
{ value: 'status' as const, label: $t('programs.filters.sort_status') || 'Status' }
]);
const filteredPrograms = $derived.by(() => {
const normalizedSearch = searchValue.trim().toLowerCase();
const sourcePrograms = programApi.programs.filter((program: Program) => {
const matchesSearch =
!normalizedSearch ||
[program.name, program.description].some((value) => value?.toLowerCase().includes(normalizedSearch));
const matchesStatus = selectedStatuses.length === 0 || selectedStatuses.includes(program.status);
return matchesSearch && matchesStatus;
});
const sortedPrograms = [...sourcePrograms];
return sortedPrograms.sort((firstProgram, secondProgram) => {
let comparison = 0;
if (sortKey === 'name') {
comparison = firstProgram.name.localeCompare(secondProgram.name);
} else if (sortKey === 'courses') {
comparison = firstProgram.courseCount - secondProgram.courseCount;
} else if (sortKey === 'students') {
comparison = firstProgram.studentCount - secondProgram.studentCount;
} else if (sortKey === 'status') {
comparison = firstProgram.status.localeCompare(secondProgram.status);
}
return selectedOrder === 'asc' ? comparison : comparison * -1;
});
});
function toggleStatus(status: string, checked: boolean) {
const nextStatuses = new SvelteSet(selectedStatuses);
if (checked) {
nextStatuses.add(status);
} else {
nextStatuses.delete(status);
}
selectedStatuses = Array.from(nextStatuses);
}
function clearFilters() {
sortKey = DEFAULT_PROGRAM_SORT;
selectedOrder = DEFAULT_PROGRAM_SORT_ORDER;
selectedStatuses = [];
}
</script>
<svelte:head>
<title>{$t('programs.page_title') || 'Programs'} - ClassroomIO</title>
</svelte:head>
<Page.Root class="w-full">
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('programs.heading') || 'Programs'}</Page.Title>
<Page.Subtitle>
{$t('programs.description') || 'Give a group of people access to multiple courses, like a class'}
</Page.Subtitle>
</Page.HeaderContent>
<Page.Action>
<Button onclick={() => (showCreateModal = true)}>
<Plus size={16} />
{$t('programs.create') || 'New Program'}
</Button>
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
{#if !programApi.isLoading && filteredPrograms.length === 0 && !searchValue.trim() && selectedStatuses.length === 0}
<Empty
title={$t('programs.empty_title') || 'No programs yet'}
description={$t('programs.empty_description') || 'Create your first program to get started.'}
icon={GoalIcon}
variant="page"
>
<Button onclick={() => (showCreateModal = true)}>
<Plus size={16} />
{$t('programs.create') || 'New Program'}
</Button>
</Empty>
{:else}
<ProgramsPage
programs={filteredPrograms}
isLoading={programApi.isLoading && filteredPrograms.length === 0}
bind:searchValue
emptyTitle={$t('programs.empty_title') || 'No programs yet'}
emptyDescription={searchValue.trim() || selectedStatuses.length > 0
? $t('programs.no_matching_programs') || 'No programs match your filters.'
: $t('programs.empty_description') || 'Create your first program to get started.'}
>
{#snippet filterControls()}
<ProgramFilterPopover
bind:sortKey
bind:selectedOrder
{selectedStatuses}
{statusOptions}
{sortOptions}
onToggleStatus={toggleStatus}
onClearFilters={clearFilters}
/>
{/snippet}
</ProgramsPage>
{/if}
{/snippet}
</Page.Body>
</Page.Root>
<CreateProgramModal bind:open={showCreateModal} />
@@ -7,29 +7,29 @@
import { Empty } from '@cio/ui/custom/empty';
import { GoalIcon } from '@cio/ui/custom/moving-icons';
import { t } from '$lib/utils/functions/translations';
import { programGoalApi } from '$features/program/api';
import type { GoalsOverviewRow } from '$features/program/utils/types';
import { cohortGoalApi } from '$features/cohort/api';
import type { GoalsOverviewRow } from '$features/cohort/utils/types';
onMount(() => {
programGoalApi.listOverview();
cohortGoalApi.listOverview();
});
const overviewRows = $derived(programGoalApi.overviewRows);
const overviewRows = $derived(cohortGoalApi.overviewRows);
const programs = $derived.by(() => {
const map = new Map<string, { programId: string; programName: string; goals: GoalsOverviewRow[] }>();
const cohorts = $derived.by(() => {
const map = new Map<string, { cohortId: string; cohortName: string; goals: GoalsOverviewRow[] }>();
for (const row of overviewRows) {
const bucket = map.get(row.programId) ?? {
programId: row.programId,
programName: row.programName,
const bucket = map.get(row.cohortId) ?? {
cohortId: row.cohortId,
cohortName: row.cohortName,
goals: []
};
bucket.goals.push(row);
map.set(row.programId, bucket);
map.set(row.cohortId, bucket);
}
return [...map.values()].sort((a, b) => a.programName.localeCompare(b.programName));
return [...map.values()].sort((a, b) => a.cohortName.localeCompare(b.cohortName));
});
function aggregateOnTrackPct(rows: GoalsOverviewRow[]): number {
@@ -46,45 +46,45 @@
</script>
<svelte:head>
<title>{$t('programs.goals.heading')} · ClassroomIO</title>
<title>{$t('cohorts.goals.heading')} · ClassroomIO</title>
</svelte:head>
<Page.Root class="w-full">
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('programs.goals.heading')}</Page.Title>
<Page.Subtitle>{$t('programs.goals.description')}</Page.Subtitle>
<Page.Title>{$t('cohorts.goals.heading')}</Page.Title>
<Page.Subtitle>{$t('cohorts.goals.description')}</Page.Subtitle>
</Page.HeaderContent>
</Page.Header>
<Page.Body>
{#snippet child()}
{#if !programGoalApi.isLoading && overviewRows.length === 0}
{#if !cohortGoalApi.isLoading && overviewRows.length === 0}
<Empty
title={$t('programs.goals.empty_title')}
description={$t('programs.goals.empty_description')}
title={$t('cohorts.goals.empty_title')}
description={$t('cohorts.goals.empty_description')}
icon={GoalIcon}
variant="page"
/>
{:else}
<div class="flex w-full flex-col gap-6 p-4">
{#each programs as program (program.programId)}
{@const onTrackPct = aggregateOnTrackPct(program.goals)}
{@const totalLearners = aggregateCount(program.goals, 'totalLearners')}
{@const overdueLearners = aggregateCount(program.goals, 'overdueCount')}
{@const atRiskLearners = aggregateCount(program.goals, 'atRiskCount')}
{#each cohorts as cohort (cohort.cohortId)}
{@const onTrackPct = aggregateOnTrackPct(cohort.goals)}
{@const totalLearners = aggregateCount(cohort.goals, 'totalLearners')}
{@const overdueLearners = aggregateCount(cohort.goals, 'overdueCount')}
{@const atRiskLearners = aggregateCount(cohort.goals, 'atRiskCount')}
<section class="flex flex-col gap-3 rounded-md border p-4">
<header class="flex flex-wrap items-baseline justify-between gap-3">
<a
href={resolve(`/programs/${program.programId}/settings`, {})}
href={resolve(`/cohorts/${cohort.cohortId}/settings`, {})}
class="text-base font-semibold hover:underline"
>
{program.programName}
{cohort.cohortName}
</a>
<div class="flex flex-wrap items-center gap-3 text-xs">
<span class="ui:text-muted-foreground">
<span class="text-base font-semibold text-emerald-600">{onTrackPct}%</span>
{$t('programs.goals.tile.on_track')}
{$t('cohorts.goals.tile.on_track')}
</span>
<span class="ui:text-muted-foreground">
<span class="font-medium">{totalLearners}</span> learners
@@ -92,13 +92,13 @@
{#if atRiskLearners > 0}
<span class="text-amber-700">
<span class="font-medium">{atRiskLearners}</span>
{$t('programs.goals.tile.at_risk_short')}
{$t('cohorts.goals.tile.at_risk_short')}
</span>
{/if}
{#if overdueLearners > 0}
<span class="text-red-600">
<span class="font-medium">{overdueLearners}</span>
{$t('programs.goals.tile.overdue_short')}
{$t('cohorts.goals.tile.overdue_short')}
</span>
{/if}
</div>
@@ -110,19 +110,19 @@
<Table.Row>
<Table.Head>Goal</Table.Head>
<Table.Head>Type</Table.Head>
<Table.Head>{$t('programs.goals.status.completed')}</Table.Head>
<Table.Head>{$t('programs.goals.status.in_progress')}</Table.Head>
<Table.Head>{$t('programs.goals.status.at_risk')}</Table.Head>
<Table.Head>{$t('programs.goals.status.overdue')}</Table.Head>
<Table.Head>{$t('programs.goals.tile.on_track')}</Table.Head>
<Table.Head>{$t('cohorts.goals.status.completed')}</Table.Head>
<Table.Head>{$t('cohorts.goals.status.in_progress')}</Table.Head>
<Table.Head>{$t('cohorts.goals.status.at_risk')}</Table.Head>
<Table.Head>{$t('cohorts.goals.status.overdue')}</Table.Head>
<Table.Head>{$t('cohorts.goals.tile.on_track')}</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each program.goals as goal (goal.goalId)}
{#each cohort.goals as goal (goal.goalId)}
<Table.Row>
<Table.Cell class="font-medium">{goal.title}</Table.Cell>
<Table.Cell>
<Badge variant="outline">{$t(`programs.goals.type.${goal.type}`)}</Badge>
<Badge variant="outline">{$t(`cohorts.goals.type.${goal.type}`)}</Badge>
</Table.Cell>
<Table.Cell>{goal.completedCount}/{goal.totalLearners}</Table.Cell>
<Table.Cell>{goal.inProgressCount}</Table.Cell>