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 <rotimi-best@users.noreply.github.com>

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

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

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

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

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

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

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

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
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
@@ -0,0 +1,199 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { untrack } from 'svelte';
import { SvelteSet } from 'svelte/reactivity';
import * as Dialog from '@cio/ui/base/dialog';
import { Button } from '@cio/ui/base/button';
import { MultiSelectList } from '@cio/ui/custom/multi-select-list';
import { t } from '$lib/utils/functions/translations';
import { cohortApi } from '../api';
import { coursesApi } from '$features/course/api';
interface Props {
open: boolean;
cohortId: string;
}
const COURSE_PAGE_SIZE = 20;
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(cohortApi.courses.map((course) => course.courseId));
return coursesApi.orgCourses.filter((course) => !existingCourseIds.has(course.id));
});
const filteredCourses = $derived.by(() => {
const normalizedSearch = searchValue.trim().toLowerCase();
if (!normalizedSearch) {
return availableCourses;
}
return availableCourses.filter((course) =>
[course.title, course.description].some((value) => value?.toLowerCase().includes(normalizedSearch))
);
});
const totalPages = $derived(Math.max(1, Math.ceil(filteredCourses.length / COURSE_PAGE_SIZE)));
const paginatedCourses = $derived(
filteredCourses.slice((currentPage - 1) * COURSE_PAGE_SIZE, currentPage * COURSE_PAGE_SIZE)
);
const selectedCount = $derived(selectedCourseIds.size);
const showLoadingState = $derived(coursesApi.isLoading);
const canGoToPreviousPage = $derived(currentPage > 1);
const canGoToNextPage = $derived(currentPage < totalPages);
const emptyMessage = $derived(
searchValue.trim()
? $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) => ({
id: course.id,
label: course.title || course.id,
description: course.description || undefined
}))
);
async function ensureCoursesLoaded() {
if (coursesApi.orgCourses.length > 0) {
return;
}
await coursesApi.getOrgCourses();
}
function toggleCourse(courseId: string) {
if (selectedCourseIds.has(courseId)) {
selectedCourseIds.delete(courseId);
return;
}
selectedCourseIds.add(courseId);
}
function handleSearchValueChange(value: string) {
searchValue = value;
currentPage = 1;
}
function closeModal() {
selectedCourseIds.clear();
searchValue = '';
currentPage = 1;
open = false;
}
function goToPage(page: number) {
currentPage = page;
}
async function handleAdd() {
const courseIds = [...selectedCourseIds];
if (courseIds.length === 0) {
return;
}
const didAddCourses = await cohortApi.addCourses(cohortId, courseIds);
if (!didAddCourses) {
return;
}
closeModal();
}
$effect(() => {
if (!open) {
return;
}
untrack(() => {
selectedCourseIds.clear();
searchValue = '';
currentPage = 1;
void ensureCoursesLoaded();
});
});
onDestroy(() => {
coursesApi.cancelOrgCoursesRequest();
});
</script>
<Dialog.Root bind:open>
<Dialog.Content class="max-w-md">
<Dialog.Header>
<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">
<MultiSelectList
class="border-0"
{emptyMessage}
items={multiSelectItems}
isLoading={showLoadingState}
isSelected={(id) => selectedCourseIds.has(id)}
onSearchValueChange={handleSearchValueChange}
onToggle={(id) => toggleCourse(id)}
namePrefix="cohort-course"
listClass="max-h-60"
searchPlaceholder={$t('cohorts.courses.search_placeholder') || 'Search courses'}
bind:searchValue
>
{#snippet headingSnippet()}
<p class="ui:text-sm ui:font-medium">
{$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 })}
</span>
{/if}
</p>
{/snippet}
</MultiSelectList>
{#if totalPages > 1}
<div class="flex items-center justify-between gap-2 text-sm">
<span class="ui:text-muted-foreground">
{$t('cohorts.courses.pagination_status', {
page: currentPage,
totalPages
}) || `Page ${currentPage} of ${totalPages}`}
</span>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onclick={() => goToPage(currentPage - 1)}
disabled={!canGoToPreviousPage || showLoadingState}
>
{$t('app.previous') || 'Previous'}
</Button>
<Button
variant="outline"
size="sm"
onclick={() => goToPage(currentPage + 1)}
disabled={!canGoToNextPage || showLoadingState}
>
{$t('app.next') || 'Next'}
</Button>
</div>
</div>
{/if}
</div>
<Dialog.Footer>
<Button variant="outline" onclick={closeModal} disabled={cohortApi.isLoading}>
{$t('app.cancel') || 'Cancel'}
</Button>
<Button onclick={handleAdd} loading={cohortApi.isLoading} disabled={selectedCount === 0 || cohortApi.isLoading}>
{$t('cohorts.courses.add') || 'Add Course'}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,45 @@
<script lang="ts">
import pluralize from 'pluralize';
import * as Item from '@cio/ui/base/item';
import { Separator } from '@cio/ui/base/separator';
import { t } from '$lib/utils/functions/translations';
import type { Cohort } from '../utils/types';
interface Props {
cohort: Cohort;
onclick?: () => void;
}
let { cohort, onclick }: Props = $props();
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}>
{#snippet child({ props })}
<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!">
{cohort.name}
</Item.Title>
<Separator class="my-3" />
<div class="flex w-full justify-between">
<div class="w-[60%]">
<p class="pl-2 text-xs dark:text-white">
{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('cohorts.card.students'), studentCount, true)}
</p>
</div>
</div>
</Item.Content>
</div>
{/snippet}
</Item.Root>
@@ -0,0 +1,141 @@
<script lang="ts">
import * as Popover from '@cio/ui/base/popover';
import * as ToggleGroup from '@cio/ui/base/toggle-group';
import { Button } from '@cio/ui/base/button';
import { Checkbox } from '@cio/ui/base/checkbox';
import { IconButton } from '@cio/ui/custom/icon-button';
import FilterIcon from '@lucide/svelte/icons/filter';
import { t } from '$lib/utils/functions/translations';
import {
DEFAULT_COHORT_SORT,
DEFAULT_COHORT_SORT_ORDER,
type CohortSortBy,
type CohortSortOrder
} from '../utils/constants';
interface StatusOption {
value: 'ACTIVE' | 'INACTIVE' | 'ARCHIVED';
label: string;
}
interface SortOption {
value: CohortSortBy;
label: string;
}
interface Props {
sortKey?: CohortSortBy;
selectedOrder?: CohortSortOrder;
selectedStatuses?: string[];
statusOptions?: StatusOption[];
sortOptions?: SortOption[];
onToggleStatus?: (status: string, checked: boolean) => void;
onClearFilters?: () => void | Promise<void>;
}
let {
sortKey = $bindable(DEFAULT_COHORT_SORT),
selectedOrder = $bindable(DEFAULT_COHORT_SORT_ORDER),
selectedStatuses = [],
statusOptions = [],
sortOptions = [],
onToggleStatus = () => {},
onClearFilters = () => {}
}: Props = $props();
let isOpen = $state(false);
const hasActiveFilters = $derived(
sortKey !== DEFAULT_COHORT_SORT || selectedOrder !== DEFAULT_COHORT_SORT_ORDER || selectedStatuses.length > 0
);
function isStatusSelected(status: string) {
return selectedStatuses.includes(status);
}
</script>
<Popover.Root bind:open={isOpen}>
<Popover.Trigger>
{#snippet child({ props })}
<div class="relative">
<IconButton {...props} aria-label={$t('cohorts.filters.filter')} tooltip={$t('cohorts.filters.filter')}>
<FilterIcon size={16} />
</IconButton>
{#if hasActiveFilters}
<span
class="ui:bg-primary absolute -top-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border border-white"
aria-hidden="true"
></span>
{/if}
</div>
{/snippet}
</Popover.Trigger>
<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('cohorts.filters.popover_title')}</p>
<Button variant="link" class="h-auto p-0" onclick={onClearFilters} disabled={!hasActiveFilters}>
{$t('cohorts.filters.clear_all')}
</Button>
</div>
<div class="space-y-2">
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">
{$t('cohorts.filters.sort')}
</p>
<div class="flex flex-wrap gap-2">
{#each sortOptions as option (option.value)}
<Button
type="button"
size="sm"
variant={sortKey === option.value ? 'secondary' : 'outline'}
onclick={() => (sortKey = option.value)}
>
{option.label}
</Button>
{/each}
</div>
</div>
<div class="space-y-2">
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">
{$t('cohorts.filters.order')}
</p>
<ToggleGroup.Root
type="single"
variant="outline"
size="sm"
value={selectedOrder}
onValueChange={(value) => {
if (value === 'asc' || value === 'desc') {
selectedOrder = value;
}
}}
>
<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('cohorts.filters.status')}
</p>
<div class="space-y-2">
{#each statusOptions as option (option.value)}
<label class="hover:ui:bg-muted/30 flex cursor-pointer items-center rounded-md border px-3 py-2">
<div class="flex items-center gap-2">
<Checkbox
checked={isStatusSelected(option.value)}
onCheckedChange={(checked) => onToggleStatus(option.value, Boolean(checked))}
/>
<span class="text-sm">{option.label}</span>
</div>
</label>
{/each}
</div>
</div>
</div>
</Popover.Content>
</Popover.Root>
@@ -0,0 +1,25 @@
<script lang="ts">
import { Separator } from '@cio/ui/base/separator';
import * as Sidebar from '@cio/ui/base/sidebar';
import { cohortApi } from '../api';
</script>
<header
class="border-border ui:bg-background sticky top-0 z-100 flex h-12 w-full shrink-0 items-center gap-2 border-b backdrop-blur transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-8"
>
<div class="flex w-full items-center gap-2 px-4">
<Sidebar.Trigger />
<div class="h-4 w-2">
<Separator orientation="vertical" />
</div>
<div class="flex min-w-0 items-center gap-2">
<p class="max-w-2xs truncate text-sm font-medium">
{cohortApi.cohort?.name || ''}
</p>
</div>
<span class="grow"></span>
</div>
</header>
@@ -0,0 +1,73 @@
<script lang="ts">
import * as Dialog from '@cio/ui/base/dialog';
import { Button } from '@cio/ui/base/button';
import { Input } from '@cio/ui/base/input';
import { Textarea } from '@cio/ui/base/textarea';
import { Label } from '@cio/ui/base/label';
import { t } from '$lib/utils/functions/translations';
import { cohortApi } from '../api';
interface Props {
open: boolean;
}
let { open = $bindable(false) }: Props = $props();
let name = $state('');
let description = $state('');
async function handleCreate() {
if (!name.trim()) return;
await cohortApi.createCohort({ name: name.trim(), description: description.trim() || undefined });
name = '';
description = '';
open = false;
}
function handleCancel() {
name = '';
description = '';
open = false;
}
</script>
<Dialog.Root bind:open>
<Dialog.Content class="max-w-md">
<Dialog.Header>
<Dialog.Title>{$t('cohorts.create_modal.title') || 'Create Cohort'}</Dialog.Title>
<Dialog.Description>
{$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('cohorts.create_modal.name_label') || 'Name'} *</Label>
<Input
id="cohort-name"
bind:value={name}
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('cohorts.create_modal.description_label') || 'Description'}</Label>
<Textarea
id="cohort-description"
bind:value={description}
placeholder={$t('cohorts.create_modal.description_placeholder') || 'What is this cohort about?'}
rows={3}
maxlength={2000}
/>
</div>
</div>
<Dialog.Footer>
<Button variant="outline" onclick={handleCancel}>{$t('app.cancel') || 'Cancel'}</Button>
<Button onclick={handleCreate} disabled={!name.trim() || cohortApi.isLoading}>
{$t('cohorts.create_modal.submit') || 'Create'}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,349 @@
<script lang="ts">
import { untrack } from 'svelte';
import * as Dialog from '@cio/ui/base/dialog';
import { Button } from '@cio/ui/base/button';
import { Input } from '@cio/ui/base/input';
import { Textarea } from '@cio/ui/base/textarea';
import { Label } from '@cio/ui/base/label';
import * as Select from '@cio/ui/base/select';
import { Checkbox } from '@cio/ui/base/checkbox';
import {
COHORT_GOAL_DEADLINE_KINDS,
COHORT_GOAL_TYPES,
type TCreateCohortGoal,
type TUpdateCohortGoal
} from '@cio/utils/validation/cohort';
import { t } from '$lib/utils/functions/translations';
import { cohortApi, cohortGoalApi } from '$features/cohort/api';
import type { CohortGoal } from '$features/cohort/utils/types';
type GoalType = (typeof COHORT_GOAL_TYPES)[number];
type DeadlineKind = (typeof COHORT_GOAL_DEADLINE_KINDS)[number];
interface Props {
open: boolean;
cohortId: string;
goal: CohortGoal | null;
onClose: () => void;
}
let { open = $bindable(), cohortId, goal, onClose }: Props = $props();
let title = $state('');
let description = $state('');
let type = $state<GoalType>('complete_all');
let courseIds = $state<string[]>([]);
let requiredCount = $state<number | null>(null);
let scoreThreshold = $state<number | null>(80);
let teamPassRateThreshold = $state<number | null>(80);
let deadlineKind = $state<DeadlineKind>('none');
let deadlineDate = $state('');
let relativeDays = $state<number | null>(null);
let recurringMonths = $state<number | null>(null);
let reminderDaysBeforeText = $state('7, 1');
let isSaving = $state(false);
let formError = $state<string | null>(null);
const isEditing = $derived(goal !== null);
const availableCourses = $derived(cohortApi.courses);
function resetFromGoal(source: CohortGoal | null) {
if (source) {
title = source.title;
description = source.description ?? '';
type = source.type;
courseIds = source.courseIds ?? [];
requiredCount = source.requiredCount ?? null;
scoreThreshold = source.scoreThreshold ?? 80;
teamPassRateThreshold = source.teamPassRateThreshold ?? 80;
deadlineKind = source.deadlineKind;
deadlineDate = source.deadlineDate ? new Date(source.deadlineDate).toISOString().slice(0, 10) : '';
relativeDays = source.relativeDays ?? null;
recurringMonths = source.recurringMonths ?? null;
reminderDaysBeforeText = (source.reminderDaysBefore ?? [7, 1]).join(', ');
formError = null;
return;
}
title = '';
description = '';
type = 'complete_all';
courseIds = [];
requiredCount = null;
scoreThreshold = 80;
teamPassRateThreshold = 80;
deadlineKind = 'none';
deadlineDate = '';
relativeDays = null;
recurringMonths = null;
reminderDaysBeforeText = '7, 1';
formError = null;
}
// Re-seed form fields only on the open→true transition or when the goal
// *reference* changes. `untrack` prevents per-property mutations on the goal
// proxy (e.g. an in-place store refresh after save) from clobbering edits.
$effect(() => {
if (!open) return;
const currentGoal = goal;
untrack(() => resetFromGoal(currentGoal));
});
// Readiness goals must use deadline=none.
$effect(() => {
if (type === 'readiness' && deadlineKind !== 'none') {
deadlineKind = 'none';
}
});
function parseReminders(text: string): number[] {
return text
.split(/[,\s]+/)
.map((part) => part.trim())
.filter(Boolean)
.map((part) => Number(part))
.filter((value) => Number.isFinite(value) && value >= 0 && value <= 365);
}
function buildPayload(): TCreateCohortGoal | null {
if (!title.trim()) {
formError = $t('cohorts.goals.modal.title_label') + ' is required';
return null;
}
if (courseIds.length === 0) {
formError = $t('cohorts.goals.modal.courses_label') + ' is required';
return null;
}
return {
title: title.trim(),
description: description.trim() || null,
type,
courseIds,
requiredCount: type === 'n_of_m' ? requiredCount : null,
scoreThreshold: type === 'score' || type === 'pass_rate' ? scoreThreshold : null,
teamPassRateThreshold: type === 'pass_rate' ? teamPassRateThreshold : null,
reminderDaysBefore: parseReminders(reminderDaysBeforeText),
deadlineKind,
deadlineDate: deadlineKind === 'absolute' && deadlineDate ? new Date(deadlineDate).toISOString() : null,
relativeDays: deadlineKind === 'relative_to_join' ? relativeDays : null,
recurringMonths: deadlineKind === 'recurring' ? recurringMonths : null
};
}
async function handleSave() {
formError = null;
const payload = buildPayload();
if (!payload) return;
isSaving = true;
try {
const ok =
isEditing && goal
? await cohortGoalApi.updateGoal(cohortId, goal.id, payload as TUpdateCohortGoal)
: await cohortGoalApi.createGoal(cohortId, payload);
if (ok) onClose();
else formError = cohortGoalApi.error ?? 'Failed to save';
} finally {
isSaving = false;
}
}
function toggleCourse(courseId: string) {
courseIds = courseIds.includes(courseId) ? courseIds.filter((id) => id !== courseId) : [...courseIds, courseId];
}
</script>
<Dialog.Root bind:open onOpenChange={(value) => !value && onClose()}>
<Dialog.Content class="max-h-[90vh] w-[96vw] max-w-2xl overflow-y-auto">
<Dialog.Header>
<Dialog.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('cohorts.goals.modal.title_label')} *</Label>
<Input
id="goal-title"
bind:value={title}
maxlength={255}
placeholder={$t('cohorts.goals.modal.title_placeholder')}
/>
</div>
<div class="flex flex-col gap-1.5">
<Label for="goal-description">{$t('cohorts.goals.modal.description_label')}</Label>
<Textarea
id="goal-description"
bind:value={description}
rows={2}
maxlength={2000}
placeholder={$t('cohorts.goals.modal.description_placeholder')}
/>
</div>
<div class="flex flex-col gap-1.5">
<Label>{$t('cohorts.goals.modal.type_label')}</Label>
<Select.Root type="single" bind:value={type}>
<Select.Trigger>{$t(`cohorts.goals.type.${type}`)}</Select.Trigger>
<Select.Content style="z-index: 500 !important">
{#each COHORT_GOAL_TYPES as goalType (goalType)}
<Select.Item value={goalType}>
{$t(`cohorts.goals.type.${goalType}`)}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="flex flex-col gap-1.5">
<Label>{$t('cohorts.goals.modal.courses_label')} *</Label>
{#if availableCourses.length === 0}
<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)}
<label class="flex items-center gap-2 text-sm">
<Checkbox
checked={courseIds.includes(item.courseId)}
onCheckedChange={() => toggleCourse(item.courseId)}
/>
<span>{item.course.title ?? 'Untitled course'}</span>
</label>
{/each}
</div>
{/if}
</div>
{#if type === 'n_of_m'}
<div class="flex flex-col gap-1.5">
<Label for="goal-required-count">{$t('cohorts.goals.modal.required_count_label')} *</Label>
<Input
id="goal-required-count"
type="number"
min="1"
max={courseIds.length || 50}
value={requiredCount ?? ''}
oninput={(event) => {
const value = (event.currentTarget as HTMLInputElement).valueAsNumber;
requiredCount = Number.isNaN(value) ? null : value;
}}
/>
</div>
{/if}
{#if type === 'score' || type === 'pass_rate'}
<div class="flex flex-col gap-1.5">
<Label for="goal-score">{$t('cohorts.goals.modal.score_threshold_label')} *</Label>
<Input
id="goal-score"
type="number"
min="0"
max="100"
value={scoreThreshold ?? ''}
oninput={(event) => {
const value = (event.currentTarget as HTMLInputElement).valueAsNumber;
scoreThreshold = Number.isNaN(value) ? null : value;
}}
/>
</div>
{/if}
{#if type === 'pass_rate'}
<div class="flex flex-col gap-1.5">
<Label for="goal-team-rate">{$t('cohorts.goals.modal.team_pass_rate_label')} *</Label>
<Input
id="goal-team-rate"
type="number"
min="0"
max="100"
value={teamPassRateThreshold ?? ''}
oninput={(event) => {
const value = (event.currentTarget as HTMLInputElement).valueAsNumber;
teamPassRateThreshold = Number.isNaN(value) ? null : value;
}}
/>
</div>
{/if}
<div class="flex flex-col gap-1.5">
<Label>{$t('cohorts.goals.modal.deadline_label')}</Label>
<Select.Root type="single" bind:value={deadlineKind} disabled={type === 'readiness'}>
<Select.Trigger>{$t(`cohorts.goals.deadline_kind.${deadlineKind}`)}</Select.Trigger>
<Select.Content style="z-index: 500 !important">
{#each COHORT_GOAL_DEADLINE_KINDS as kind (kind)}
<Select.Item value={kind}>
{$t(`cohorts.goals.deadline_kind.${kind}`)}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
{#if deadlineKind === 'absolute'}
<div class="flex flex-col gap-1.5">
<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('cohorts.goals.modal.relative_days_label')} *</Label>
<Input
id="goal-relative-days"
type="number"
min="1"
max="3650"
value={relativeDays ?? ''}
oninput={(event) => {
const value = (event.currentTarget as HTMLInputElement).valueAsNumber;
relativeDays = Number.isNaN(value) ? null : value;
}}
/>
</div>
{/if}
{#if deadlineKind === 'recurring'}
<div class="flex flex-col gap-1.5">
<Label for="goal-recurring-months">{$t('cohorts.goals.modal.recurring_months_label')} *</Label>
<Input
id="goal-recurring-months"
type="number"
min="1"
max="120"
value={recurringMonths ?? ''}
oninput={(event) => {
const value = (event.currentTarget as HTMLInputElement).valueAsNumber;
recurringMonths = Number.isNaN(value) ? null : value;
}}
/>
</div>
{/if}
{#if deadlineKind !== 'none'}
<div class="flex flex-col gap-1.5">
<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('cohorts.goals.modal.reminders_helper')}</p>
</div>
{/if}
{#if formError}
<p class="text-sm text-red-600">{formError}</p>
{/if}
<div class="mt-2 flex justify-end gap-2">
<Button variant="ghost" onclick={onClose} disabled={isSaving}>
{$t('cohorts.goals.modal.cancel')}
</Button>
<Button variant="secondary" onclick={handleSave} loading={isSaving} disabled={isSaving}>
{$t('cohorts.goals.modal.save')}
</Button>
</div>
</div>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,77 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Badge } from '@cio/ui/base/badge';
import { t } from '$lib/utils/functions/translations';
import { cohortGoalApi } from '$features/cohort/api';
import type { CohortGoal } from '$features/cohort/utils/types';
interface Props {
cohortId: string;
}
let { cohortId }: Props = $props();
const goals = $derived(cohortGoalApi.goalsFor(cohortId));
onMount(() => {
cohortGoalApi.listGoals(cohortId);
});
function totalLearners(goal: CohortGoal): number {
return Object.values(goal.statusCounts ?? {}).reduce((sum, value) => sum + (value ?? 0), 0);
}
function statusValue(goal: CohortGoal, status: string): number {
return goal.statusCounts?.[status] ?? 0;
}
function onTrackPct(goal: CohortGoal): number {
const total = totalLearners(goal);
if (total === 0) return 0;
const onTrack =
statusValue(goal, 'completed') + statusValue(goal, 'in_progress') + statusValue(goal, 'not_started');
return Math.round((onTrack / total) * 100);
}
</script>
{#if goals.length > 0}
<section class="mb-4">
<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)}
{@const completed = statusValue(goal, 'completed')}
{@const overdue = statusValue(goal, 'overdue')}
{@const atRisk = statusValue(goal, 'at_risk')}
<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(`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('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('cohorts.goals.tile.completed_short')}
</span>
{#if atRisk > 0}
<span class="text-amber-700">
<span class="font-medium">{atRisk}</span>
{' '}{$t('cohorts.goals.tile.at_risk_short')}
</span>
{/if}
{#if overdue > 0}
<span class="text-red-600">
<span class="font-medium">{overdue}</span>
{' '}{$t('cohorts.goals.tile.overdue_short')}
</span>
{/if}
</div>
</div>
{/each}
</div>
</section>
{/if}
@@ -0,0 +1,149 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Button } from '@cio/ui/base/button';
import { Badge } from '@cio/ui/base/badge';
import { IconButton } from '@cio/ui/custom/icon-button';
import PencilIcon from '@lucide/svelte/icons/pencil';
import PlusIcon from '@lucide/svelte/icons/plus';
import TrashIcon from '@lucide/svelte/icons/trash';
import { t } from '$lib/utils/functions/translations';
import { cohortGoalApi } from '$features/cohort/api';
import type { CohortGoal } from '$features/cohort/utils/types';
import GoalFormModal from './goal-form-modal.svelte';
interface Props {
cohortId: string;
canManage: boolean;
}
let { cohortId, canManage }: Props = $props();
let isModalOpen = $state(false);
let editingGoal = $state<CohortGoal | null>(null);
const goals = $derived(cohortGoalApi.goalsFor(cohortId));
onMount(() => {
cohortGoalApi.listGoals(cohortId);
});
function openCreate() {
editingGoal = null;
isModalOpen = true;
}
function openEdit(goal: CohortGoal) {
editingGoal = goal;
isModalOpen = true;
}
async function deleteGoal(goal: CohortGoal) {
const confirmMessage = $t('cohorts.goals.delete_confirm');
if (!confirm(confirmMessage)) return;
await cohortGoalApi.deleteGoal(cohortId, goal.id);
}
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();
}
if (goal.deadlineKind === 'relative_to_join' && goal.relativeDays) {
return `+${goal.relativeDays}d after join`;
}
if (goal.deadlineKind === 'recurring' && goal.recurringMonths) {
return `Repeats every ${goal.recurringMonths}mo`;
}
return $t('cohorts.goals.lms.no_deadline');
}
function totalLearners(goal: CohortGoal): number {
return Object.values(goal.statusCounts ?? {}).reduce((sum, value) => sum + (value ?? 0), 0);
}
function statusValue(goal: CohortGoal, status: string): number {
return goal.statusCounts?.[status] ?? 0;
}
</script>
<section>
<div class="mb-3 flex items-start justify-between gap-3">
<div>
<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('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('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">
{#each goals as goal (goal.id)}
{@const total = totalLearners(goal)}
{@const completed = statusValue(goal, 'completed')}
{@const overdue = statusValue(goal, 'overdue')}
{@const atRisk = statusValue(goal, 'at_risk')}
<div class="flex flex-col gap-3 rounded-md border p-4">
<div class="flex items-start justify-between gap-3">
<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(`cohorts.goals.type.${goal.type}`)}</Badge>
</div>
{#if goal.description}
<p class="ui:text-muted-foreground mt-1 text-sm">{goal.description}</p>
{/if}
<p class="ui:text-muted-foreground mt-2 text-xs">
{goal.courseIds.length} courses · {summarizeDeadline(goal)}
</p>
</div>
{#if canManage}
<div class="flex items-center gap-1">
<IconButton onclick={() => openEdit(goal)}>
<PencilIcon size={16} />
</IconButton>
<IconButton onclick={() => deleteGoal(goal)}>
<TrashIcon size={16} />
</IconButton>
</div>
{/if}
</div>
<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('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('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('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('cohorts.goals.status.overdue')}</p>
<p class="text-base font-semibold">{overdue}</p>
</div>
</div>
</div>
{/each}
</div>
{/if}
</section>
<GoalFormModal bind:open={isModalOpen} {cohortId} goal={editingGoal} onClose={() => (isModalOpen = false)} />
@@ -0,0 +1,88 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Badge } from '@cio/ui/base/badge';
import { t } from '$lib/utils/functions/translations';
import { cohortGoalApi } from '$features/cohort/api';
interface Props {
/** When set, only show goals from this cohort. */
cohortId?: string;
}
let { cohortId }: Props = $props();
onMount(() => {
cohortGoalApi.listMyGoals();
});
const myGoals = $derived(cohortGoalApi.myGoals.filter((assignment) => !cohortId || assignment.cohortId === cohortId));
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('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('cohorts.goals.lms.due_in_days').replace('{days}', String(days));
}
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));
}
function statusVariant(status: string) {
if (status === 'completed') return 'default';
if (status === 'overdue') return 'destructive';
if (status === 'at_risk') return 'outline';
return 'secondary';
}
</script>
{#if myGoals.length > 0}
<section class="mb-4">
<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 !cohortId}
<p class="ui:text-muted-foreground text-xs">{assignment.cohortName}</p>
{/if}
</div>
<Badge variant={statusVariant(assignment.status)}>
{$t(`cohorts.goals.status.${assignment.status}`)}
</Badge>
</div>
<div class="flex flex-wrap items-center gap-3 text-xs">
<span class="ui:text-muted-foreground">
{assignment.completedCount}/{assignment.requiredCount} ({progressPct(assignment)}%)
</span>
<span
class={assignment.status === 'overdue'
? 'text-red-600'
: assignment.status === 'at_risk'
? 'text-amber-700'
: 'ui:text-muted-foreground'}
>
{formatDue(assignment)}
</span>
</div>
<div class="h-1.5 w-full overflow-hidden rounded-full bg-gray-100">
<div class="h-full bg-emerald-500 transition-[width]" style="width: {progressPct(assignment)}%"></div>
</div>
</div>
{/each}
</div>
</section>
{/if}
@@ -0,0 +1,228 @@
<script lang="ts">
import { untrack } from 'svelte';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import * as Dialog from '@cio/ui/base/dialog';
import * as UnderlineTabs from '@cio/ui/custom/underline-tabs';
import { Button } from '@cio/ui/base/button';
import { ROLE } from '@cio/utils/constants';
import { t } from '$lib/utils/functions/translations';
import { currentOrg } from '$lib/utils/store/org';
import { orgApi } from '$features/org/api/org.svelte';
import { DEFAULT_ORG_AUDIENCE_QUERY } from '$features/org/utils/audience-query-utils';
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 { cohortApi } from '../api';
interface Props {
cohortId: string;
}
let { cohortId }: Props = $props();
let tutors = $state<Tutor[]>([]);
let selectedIds = $state<string[]>([]);
let isLoadingStudents = $state(false);
let activeTab = $state<'tutors' | 'students'>('students');
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, cohortApi.members));
const INVITE_MODAL = 'course.navItem.people.invite_modal';
function getTutors(team: OrgTeamMember[]) {
const existingTutorIds = new Set(
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))
);
return team
.filter((teamMember) => teamMember.verified)
.filter((teamMember) => !existingTutorIds.has(teamMember.profileId))
.map((teamMember) => ({
id: teamMember.id,
text: teamMember.fullname,
profileId: teamMember.profileId,
email: teamMember.email
}));
}
function getAvailableStudents(students: OrgStudent[], cohortMembers: typeof cohortApi.members) {
const existingStudentIds = new Set(
cohortMembers
.filter((member) => Number(member.roleId) === ROLE.STUDENT)
.map((member) => member.profileId)
.filter((profileId): profileId is string => Boolean(profileId))
);
return students.filter((student) => {
if (!student.profileId) {
return false;
}
return !existingStudentIds.has(student.profileId);
});
}
function closeModal() {
goto(resolve(page.url.pathname, {}));
}
function setTutors(orgId: string | undefined) {
if (!orgId) {
return;
}
untrack(async () => {
await orgApi.getOrgTeam();
if (orgApi.error) {
console.error('Error fetching teams', orgApi.error);
return;
}
tutors = getTutors(orgApi.teamMembers);
});
}
function loadStudents(orgId: string | undefined) {
if (!orgId) {
return;
}
untrack(async () => {
isLoadingStudents = true;
try {
await orgApi.getOrgAudience(orgId, DEFAULT_ORG_AUDIENCE_QUERY, { abortPrevious: true });
} finally {
isLoadingStudents = false;
}
});
}
async function handleStudentSearch(value: string) {
const orgId = $currentOrg.id;
if (!orgId) {
return;
}
isLoadingStudents = true;
try {
await orgApi.getOrgAudience(
orgId,
{
...DEFAULT_ORG_AUDIENCE_QUERY,
search: value.trim() || undefined
},
{ abortPrevious: true }
);
} finally {
isLoadingStudents = false;
}
}
async function addTutors() {
if (!selectedTutors.length) {
closeModal();
return;
}
cohortApi.success = false;
await cohortApi.addMembers(cohortId, {
members: selectedTutors.map((tutor) => ({
profileId: tutor.profileId,
roleId: ROLE.TUTOR,
email: tutor.email,
name: tutor.text
}))
});
if (cohortApi.success) {
closeModal();
}
}
async function assignExistingStudents(profileIds: string[], sendEmail: boolean) {
await cohortApi.assignExistingStudentsToCohort(cohortId, { profileIds, sendEmail });
}
async function inviteNewStudents(recipientCsv: string, sendEmail: boolean) {
await cohortApi.inviteStudentsToCohort(cohortId, { recipientCsv, sendEmail });
}
$effect(() => {
setTutors($currentOrg.id);
});
$effect(() => {
if (!isOpen || !cohortId) {
return;
}
untrack(() => {
activeTab = 'students';
selectedIds = [];
void loadStudents($currentOrg.id);
});
});
</script>
<Dialog.Root
open={isOpen}
onOpenChange={(open) => {
if (!open) closeModal();
}}
>
<Dialog.Content class="max-h-[80vh] w-[96vw] max-w-3xl! overflow-y-auto">
<Dialog.Header>
<Dialog.Title>{$t('cohorts.people.invite_modal_title')}</Dialog.Title>
</Dialog.Header>
<UnderlineTabs.Root bind:value={activeTab}>
<UnderlineTabs.List class="flex flex-wrap">
<UnderlineTabs.Trigger value="tutors">
{$t(`${INVITE_MODAL}.invite`)}
</UnderlineTabs.Trigger>
<UnderlineTabs.Trigger value="students">
{$t(`${INVITE_MODAL}.invite_students`)}
</UnderlineTabs.Trigger>
</UnderlineTabs.List>
<UnderlineTabs.Content value="tutors">
<div class="space-y-3">
<TutorSelectSection
{tutors}
bind:selectedIds
isLoading={orgApi.isLoading}
helperHref={`/org/${$currentOrg.siteName}/settings/teams`}
/>
<div class="mt-5 flex flex-row-reverse items-center gap-2">
<Button variant="secondary" type="button" onclick={addTutors} loading={cohortApi.isLoading}>
{$t(`${INVITE_MODAL}.finish`)}
</Button>
</div>
</div>
</UnderlineTabs.Content>
<UnderlineTabs.Content value="students">
<div class="space-y-6">
<ExistingStudentsSection
students={availableStudents}
isLoading={isLoadingStudents}
onSearchValueChange={handleStudentSearch}
onAssign={assignExistingStudents}
/>
<BulkEmailSection onInvite={inviteNewStudents} />
</div>
</UnderlineTabs.Content>
</UnderlineTabs.Root>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,91 @@
<script lang="ts">
import pluralize from 'pluralize';
import { resolve } from '$app/paths';
import { goto } from '$app/navigation';
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 { Cohort } from '../utils/types';
interface Props {
cohort: Cohort;
isLMS?: boolean;
href?: string;
}
let { cohort, isLMS = false, href }: Props = $props();
const courseCount = $derived(cohort.courseCount ?? 0);
const studentCount = $derived(cohort.studentCount ?? 0);
function handleRowClick() {
if (href) {
goto(resolve(href, {}));
return;
}
const nextRoute = isLMS ? `/cohorts/${cohort.id}/newsfeed?source=lms` : `/cohorts/${cohort.id}/newsfeed`;
goto(resolve(nextRoute, {}));
}
function getStatusVariant(status: Cohort['status']) {
if (status === 'ACTIVE') {
return 'success';
}
if (status === 'INACTIVE') {
return 'secondary';
}
return 'outline';
}
function getStatusLabelKey(status: Cohort['status']) {
if (status === 'ACTIVE') {
return 'cohorts.status.active';
}
if (status === 'INACTIVE') {
return 'cohorts.status.inactive';
}
return 'cohorts.status.archived';
}
</script>
<ResourceListRow.Root
variant="default"
align="start"
class="ui:cursor-pointer ui:py-3"
onclick={handleRowClick}
role="row"
>
<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">{cohort.name}</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(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('cohorts.card.courses'), courseCount, true)}
</p>
{#if !isLMS}
<p class="ui:text-sm ui:text-muted-foreground">
{pluralize(t.get('cohorts.card.students'), studentCount, true)}
</p>
{/if}
</div>
</div>
</ResourceListRow.Root>
@@ -0,0 +1,130 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import type { CohortNewsfeedItem } from '$features/cohort/utils/types';
import { cohortNewsfeedApi, type CohortNewsfeedCommentsByFeedId } from '$features/cohort/api';
import {
getNewsfeedReactionCounts,
getSelectedNewsfeedReactionType,
NewsfeedReactions
} from '@cio/ui/custom/newsfeed-reactions';
import DeleteConfirmation from './delete-confirmation.svelte';
import Header from './header.svelte';
import Comments from './comments.svelte';
interface Props {
feed: CohortNewsfeedItem;
comments?: CohortNewsfeedCommentsByFeedId;
cohortId?: string;
editFeed: CohortNewsfeedItem | null;
author: {
id: string;
username: string;
fullname: string;
avatarUrl: string;
};
reactionAuthorId: string;
canComment?: boolean;
canManageFeed?: boolean;
edit?: boolean;
deleteFeed?: (feedId: string) => void;
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: CohortNewsfeedItem['id'], isPinned: CohortNewsfeedItem['isPinned']) => void;
isActive?: boolean;
isReacting?: boolean;
}
let {
feed,
comments,
cohortId,
editFeed = $bindable(),
author,
reactionAuthorId,
canComment = true,
canManageFeed = false,
edit = $bindable(false),
deleteFeed = (_feedId: string) => {},
deleteComment = (_feedId: string, _commentId: string) => {},
addNewComment = (_content: string, _feedId: string) => {},
addNewReaction = (_reactionType: string, _feedId: string, _authorId: string) => {},
onPin = (_feedId: string, _isPinned: boolean | null) => {},
isActive = false,
isReacting = false
}: Props = $props();
let isDeleteFeedModal = $state(false);
const reactionCounts = $derived(getNewsfeedReactionCounts(feed.reaction));
const selectedReactionType = $derived(getSelectedNewsfeedReactionType(feed.reaction, reactionAuthorId));
const openEditFeed = async () => {
editFeed = feed;
edit = true;
await tick();
cohortNewsfeedApi.openNewFeedModal();
};
const handleReact = (reactionType: string) => addNewReaction(reactionType, feed.id, reactionAuthorId);
onMount(() => {
if (!isActive) {
return;
}
const element = document.getElementById(feed.id);
element?.scrollIntoView({
block: 'start',
behavior: 'smooth',
inline: 'nearest'
});
});
</script>
<div
id={feed.id}
class="mb-7 flex w-full max-w-3xl flex-col justify-between gap-2 rounded-md {isActive
? 'border-primary-700 border-2'
: 'ui:border'}"
>
<Header
{feed}
{canManageFeed}
{onPin}
onEdit={openEditFeed}
onRequestDelete={() => {
isDeleteFeedModal = true;
}}
/>
<div class="px-3 pt-1 pb-2">
<NewsfeedReactions
{reactionCounts}
{selectedReactionType}
disabled={isReacting || !reactionAuthorId}
onReactionToggle={handleReact}
/>
</div>
<Comments
{cohortId}
{feed}
{author}
{comments}
{canComment}
onAddComment={async (content) => {
await addNewComment(content, feed.id);
}}
onDeleteComment={(commentId) => {
deleteComment(feed.id, String(commentId));
}}
/>
<DeleteConfirmation
bind:openDeleteModal={isDeleteFeedModal}
deleteFeed={() => {
deleteFeed(feed.id);
}}
/>
</div>
@@ -0,0 +1,206 @@
<script lang="ts">
import pluralize from 'pluralize';
import UsersIcon from '@lucide/svelte/icons/users';
import SendHorizontalIcon from '@lucide/svelte/icons/send-horizontal';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import { profile } from '$lib/utils/store/user';
import { isOrgAdmin } from '$lib/utils/store/org';
import { calDateDiff } from '$lib/utils/functions/date';
import { t } from '$lib/utils/functions/translations';
import { cohortNewsfeedApi } from '$features/cohort/api';
import { Button } from '@cio/ui/base/button';
import { UserAvatar } from '@cio/ui/custom/user-avatar';
import type { CohortNewsfeedItem } from '$features/cohort/utils/types';
import type { CohortNewsfeedCommentsByFeedId } from '$features/cohort/api';
interface Props {
cohortId?: string;
feed: CohortNewsfeedItem;
author: {
id: string;
username: string;
fullname: string;
avatarUrl: string;
};
canComment?: boolean;
comments?: CohortNewsfeedCommentsByFeedId;
onAddComment: (content: string) => Promise<void> | void;
onDeleteComment: (commentId: string | number) => void;
}
let { cohortId, feed, author, canComment = true, comments, onAddComment, onDeleteComment }: Props = $props();
let comment = $state('');
let isSubmitting = $state(false);
let isBootstrapping = $state(false);
let didBootstrap = $state(false);
const pageSize = 5;
let visibleCount = $state(1);
const loadedCount = $derived((comments?.items?.length ?? 0) as number);
const totalCount = $derived(comments?.totalCount || feed.commentCount);
const isLoading = $derived((comments?.isLoading ?? false) as boolean);
const shownCount = $derived(Math.min(visibleCount, loadedCount));
const bootstrap = async () => {
if (!cohortId || !comments) return;
if (didBootstrap) return;
didBootstrap = true;
isBootstrapping = true;
try {
await cohortNewsfeedApi.getComments(cohortId, feed.id);
} finally {
isBootstrapping = false;
}
};
const submitComment = async () => {
if (isSubmitting) return;
isSubmitting = true;
try {
await onAddComment(comment);
comment = '';
} finally {
isSubmitting = false;
}
};
const handleAddNewComment = async (event: KeyboardEvent | MouseEvent) => {
if (isSubmitting) return;
const maybeKey = event as KeyboardEvent & { key?: string };
if (maybeKey.key !== 'Enter' && event.type !== 'click') {
return;
}
event.preventDefault();
await submitComment();
};
const showMore = async () => {
if (!comments) {
return;
}
if (!didBootstrap) {
await bootstrap();
}
visibleCount = visibleCount <= 1 ? pageSize : visibleCount + pageSize;
};
const minimize = () => {
visibleCount = visibleCount > 1 ? 1 : shownCount;
};
const toggleComments = async () => {
if (totalCount === shownCount) {
minimize();
return;
}
await showMore();
};
</script>
<section class="border-t border-gray-200 p-3">
<div class="flex w-full items-center justify-between">
<Button
variant="ghost"
size="sm"
onclick={toggleComments}
loading={isBootstrapping || isLoading}
class="justify-start"
>
<UsersIcon size={16} />
<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('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('cohorts.newsfeed.comments.hide')}</span>
</Button>
{/if}
</div>
<div>
{#if comments}
{#each comments.items.slice(0, shownCount) as commentItem (commentItem.id)}
{#if commentItem.content}
<div class="group flex items-center justify-between py-2">
<span class="flex items-center gap-3">
<div class="h-9 w-9">
<img
src={commentItem.authorAvatarUrl || ''}
alt={$t('cohorts.newsfeed.user_avatar_alt')}
class="h-full w-full rounded-full object-cover"
/>
</div>
<span>
<div class="flex items-center gap-2">
<p class="text-sm font-medium capitalize">{commentItem.authorFullname || ''}</p>
<p class="text-xs font-medium text-gray-600">{calDateDiff(commentItem.createdAt)}</p>
</div>
<p>{commentItem.content}</p>
</span>
</span>
{#if commentItem.authorProfileId === $profile.id || $isOrgAdmin}
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="hidden h-8 w-8 items-center justify-center rounded-md group-hover:flex hover:bg-gray-100 dark:hover:bg-neutral-700"
>
<EllipsisVerticalIcon class="h-5 w-5" />
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item class="text-red-600" onclick={() => onDeleteComment(commentItem.id)}>
{$t('cohorts.newsfeed.comments.delete')}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
{/if}
</div>
{/if}
{/each}
{/if}
</div>
{#if canComment}
<div class="flex items-center justify-between gap-2">
<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('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
/>
</div>
<Button
variant="ghost"
size="icon-sm"
class="rounded-full"
loading={isSubmitting}
onclick={handleAddNewComment}
aria-label={$t('cohorts.newsfeed.comments.submit_aria')}
>
<SendHorizontalIcon size={16} />
</Button>
</div>
{/if}
</section>
@@ -0,0 +1,37 @@
<script lang="ts">
import * as Dialog from '@cio/ui/base/dialog';
import { Button } from '@cio/ui/base/button';
import { t } from '$lib/utils/functions/translations';
interface Props {
openDeleteModal?: boolean;
deleteFeed?: () => void;
}
let { openDeleteModal = $bindable(false), deleteFeed = () => {} }: Props = $props();
const handleDelete = async () => {
deleteFeed();
openDeleteModal = false;
};
</script>
<Dialog.Root bind:open={openDeleteModal}>
<Dialog.Content class="w-96">
<Dialog.Header>
<Dialog.Title>{$t('cohorts.newsfeed.delete_feed_title')}</Dialog.Title>
</Dialog.Header>
<div>
<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('cohorts.newsfeed.cancel')}
</Button>
<Button variant="outline" onclick={handleDelete}>
{$t('cohorts.newsfeed.confirm_delete')}
</Button>
</div>
</div>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,65 @@
<script lang="ts">
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import { calDateDiff } from '$lib/utils/functions/date';
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 { CohortNewsfeedItem } from '$features/cohort/utils/types';
import { HTMLRender } from '$features/ui';
interface Props {
feed: CohortNewsfeedItem;
canManageFeed?: boolean;
onPin: (feedId: CohortNewsfeedItem['id'], isPinned: CohortNewsfeedItem['isPinned']) => void;
onEdit: () => void;
onRequestDelete: () => void;
}
let { feed, canManageFeed = false, onPin, onEdit, onRequestDelete }: Props = $props();
</script>
<div class="px-3 pt-3 pb-0">
<div class="mb-2 flex justify-between">
<span class="flex items-center gap-3">
<div class="h-9 w-9">
<img
src={feed.authorAvatarUrl || ''}
alt={$t('cohorts.newsfeed.user_avatar_alt')}
class="h-full w-full rounded-full object-cover"
/>
</div>
<span>
<p class="text-base font-semibold capitalize">{feed.authorFullname || ''}</p>
<p class="text-sm font-medium text-gray-600">{calDateDiff(feed.createdAt)}</p>
</span>
</span>
{#if canManageFeed}
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="flex h-8 w-8 items-center justify-center rounded-md hover:bg-gray-100 dark:hover:bg-neutral-700"
>
<EllipsisVerticalIcon class="h-5 w-5" />
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={() => onPin(feed.id, feed.isPinned)}>
{feed.isPinned ? $t('cohorts.newsfeed.card.unpin') : $t('cohorts.newsfeed.card.pin')}
</DropdownMenu.Item>
<DropdownMenu.Item onclick={onEdit}>{$t('cohorts.newsfeed.card.edit')}</DropdownMenu.Item>
<DropdownMenu.Item class="text-red-600" onclick={onRequestDelete}>
{$t('cohorts.newsfeed.card.delete')}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
{/if}
</div>
{#if !isHtmlValueEmpty(feed.content || '')}
<HTMLRender className="w-[80%]">
<div>
<SafeHtmlContent content={feed.content || ''} />
</div>
</HTMLRender>
{/if}
</div>
@@ -0,0 +1,2 @@
export { default as NewFeedModal } from './new-feed-modal.svelte';
export { default as NewsFeedCard } from './card/card.svelte';
@@ -0,0 +1,109 @@
<script lang="ts">
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$features/ui/snackbar/store';
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 {
cohortId?: string;
edit?: boolean;
editFeed: CohortNewsfeedItem | null;
}
let { cohortId = '', edit = $bindable(false), editFeed = $bindable() }: Props = $props();
let isLoading = $state(false);
let newPost = $state('');
$effect(() => {
if (!cohortNewsfeedApi.isNewFeedModalOpen) {
return;
}
if (edit && editFeed) {
newPost = editFeed.content || '';
return;
}
newPost = '';
});
const resetEditor = () => {
newPost = '';
edit = false;
editFeed = null;
cohortNewsfeedApi.closeNewFeedModal();
};
const onPost = async () => {
isLoading = true;
try {
if (edit && editFeed) {
await cohortNewsfeedApi.update(cohortId, editFeed.id, { content: newPost ?? '' });
if (cohortNewsfeedApi.success) {
resetEditor();
} else {
snackbar.error(getErrorKey(cohortNewsfeedApi.error, 'update'));
}
} else {
await cohortNewsfeedApi.create(cohortId, {
content: newPost ?? '',
isPinned: false
});
if (cohortNewsfeedApi.success) {
resetEditor();
} else {
snackbar.error(getErrorKey(cohortNewsfeedApi.error, 'create'));
}
}
} catch {
snackbar.error(getErrorKey(cohortNewsfeedApi.error, edit ? 'update' : 'create'));
} finally {
isLoading = false;
}
};
</script>
<Dialog.Root
bind:open={cohortNewsfeedApi.isNewFeedModalOpen}
onOpenChange={(isOpen) => {
if (!isOpen) {
resetEditor();
}
}}
>
<Dialog.Content class="h-fit! w-fit! max-w-none!">
<Dialog.Header>
<Dialog.Title>
{edit ? $t('cohorts.newsfeed.edit_post') : $t('cohorts.newsfeed.make_a_post')}
</Dialog.Title>
</Dialog.Header>
<section class="max-w-lg">
{#key edit ? editFeed?.id : 'new'}
<TextEditor
content={newPost}
onChange={(text) => {
newPost = text;
}}
editorClass="max-h-[400px]"
placeholder={$t('cohorts.newsfeed.editor_placeholder')}
/>
{/key}
</section>
<Dialog.Footer>
<Button variant="outline" onclick={resetEditor}>
{$t('cohorts.newsfeed.cancel')}
</Button>
<Button loading={isLoading} onclick={onPost}>
{$t('cohorts.newsfeed.post_submit')}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,105 @@
<script lang="ts">
import { page } from '$app/state';
import { resolve } from '$app/paths';
import * as Sidebar from '@cio/ui/base/sidebar';
import { BackButton } from '@cio/ui';
import { HoverableItem, NewsFeedIcon, CourseIcon, PeopleIcon, SettingsIcon } from '@cio/ui/custom/moving-icons';
import { t } from '$lib/utils/functions/translations';
import { currentOrgPath, isOrgAdmin } from '$lib/utils/store/org';
import { isStudentExperience } from '$lib/utils/store/app';
import { profile } from '$lib/utils/store/user';
import { cohortApi } from '$features/cohort/api';
import { ROLE } from '@cio/utils/constants';
interface Props {
path: string;
id: string;
}
let { path, id }: Props = $props();
const currentPath = $derived(path || page.url.pathname);
const isLmsCohortContext = $derived(page.url.searchParams.get('source') === 'lms' || $isStudentExperience);
const cohortsListPath = $derived(isLmsCohortContext ? '/lms/cohorts' : `${$currentOrgPath}/cohorts`);
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(
$isOrgAdmin === true || (currentMemberRole !== null && currentMemberRole <= ROLE.TUTOR)
);
// Only Admins and org admins can access settings
const canManageSettings = $derived($isOrgAdmin === true || currentMemberRole === ROLE.ADMIN);
const navItems = $derived(
[
{
id: 'newsfeed',
title: $t('cohorts.sidebar.newsfeed') || 'Newsfeed',
url: `/cohorts/${id}/newsfeed`,
isActive: currentPath.endsWith('/newsfeed'),
icon: NewsFeedIcon,
show: () => true
},
{
id: 'courses',
title: $t('cohorts.sidebar.courses') || 'Courses',
url: `/cohorts/${id}/courses`,
isActive: currentPath.endsWith('/courses'),
icon: CourseIcon,
show: () => true
},
{
id: 'people',
title: $t('cohorts.sidebar.people') || 'People',
url: `/cohorts/${id}/people`,
isActive: currentPath.endsWith('/people'),
icon: PeopleIcon,
show: () => canManageMembers
},
{
id: 'settings',
title: $t('cohorts.sidebar.settings') || 'Settings',
url: `/cohorts/${id}/settings`,
isActive: currentPath.endsWith('/settings'),
icon: SettingsIcon,
show: () => canManageSettings
}
].filter((item) => item.show())
);
</script>
<Sidebar.Group class="pt-0!">
<BackButton
href={resolve(cohortsListPath, {})}
label={$t(isLmsCohortContext ? 'lms_navigation.cohorts' : 'org_navigation.cohorts') || 'Cohorts'}
class="px-2! py-2!"
/>
<Sidebar.Menu>
{#each navItems as item (item.id)}
<Sidebar.MenuItem>
<Sidebar.MenuButton tooltipContent={item.title} isActive={item.isActive}>
{#snippet child({ props })}
<HoverableItem>
{#snippet children(isHovered)}
{@const Icon = item.icon}
<a href={resolve(item.url, {})} {...props}>
<Icon {isHovered} size={16} />
<span>{item.title}</span>
</a>
{/snippet}
</HoverableItem>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
{/each}
</Sidebar.Menu>
</Sidebar.Group>
<style>
a {
text-decoration: none;
}
</style>
@@ -0,0 +1,70 @@
<script lang="ts">
import { page } from '$app/state';
import * as Sidebar from '@cio/ui/base/sidebar';
import { Skeleton } from '@cio/ui/base/skeleton';
import { profile } from '$lib/utils/store/user';
import { orgs } from '$lib/utils/store/org';
import OrgLogo from '$features/ui/sidebar/org-sidebar/org-logo.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';
const SIDEBAR_ITEM_SKELETON_COUNT = 4;
interface Props {
path: string;
id: string;
isCohortReady?: boolean;
}
let { path, id, isCohortReady = true }: Props = $props();
const isOrgLoaded = $derived($orgs.length > 0 && $profile.id);
const sidebar = useSidebar();
const currentPath = $derived(path || page.url.pathname);
</script>
{#if !isOrgLoaded}
<SidebarSkeleton />
{:else}
<Sidebar.Root collapsible="icon">
<Sidebar.Header>
<OrgLogo />
</Sidebar.Header>
<Sidebar.Content>
{#if isCohortReady}
<Navigation path={currentPath} {id} />
{:else}
<Sidebar.Group class="pt-0!">
<div class="flex h-8 items-center gap-2 rounded-md px-2">
<Skeleton class="ui:size-4 ui:rounded-md" />
<Skeleton class="ui:h-4 ui:w-24" />
</div>
<Sidebar.Menu>
{#each Array(SIDEBAR_ITEM_SKELETON_COUNT) as _, i (i)}
<Sidebar.MenuItem>
<div class="ui:flex ui:h-8 ui:items-center ui:gap-2 ui:rounded-md ui:px-2" data-sidebar="menu-skeleton">
<Skeleton class="ui:size-4 ui:rounded-md" />
<Skeleton class="ui:h-4 ui:max-w-32 ui:flex-1" />
</div>
</Sidebar.MenuItem>
{/each}
</Sidebar.Menu>
</Sidebar.Group>
{/if}
</Sidebar.Content>
<Sidebar.Rail />
<Sidebar.Footer>
<PoweredBy
variant="sidebar"
sidebarUtmSource="lms-cohort-sidebar"
showOnlyLogo={!sidebar.open || sidebar.isMobile}
/>
</Sidebar.Footer>
</Sidebar.Root>
{/if}
@@ -0,0 +1 @@
export { default as CohortSidebar } from './cohort-sidebar.svelte';