diff --git a/src/main/webapp/app/core/course/overview/services/course-overview.service.ts b/src/main/webapp/app/core/course/overview/services/course-overview.service.ts index b9105962eb8f..9960e452e8fd 100644 --- a/src/main/webapp/app/core/course/overview/services/course-overview.service.ts +++ b/src/main/webapp/app/core/course/overview/services/course-overview.service.ts @@ -304,6 +304,7 @@ export class CourseOverviewService { id: lecture.id ?? '', subtitleLeft: lecture.startDate?.format('MMM DD, YYYY') ?? this.translate.instant('artemisApp.courseOverview.sidebar.noDate'), size: 'M', + startDate: lecture.startDate, }; } mapTutorialGroupToSidebarCardElement(tutorialGroup: TutorialGroup): SidebarCardElement { diff --git a/src/main/webapp/app/shared/sidebar/sidebar-accordion/sidebar-accordion.component.html b/src/main/webapp/app/shared/sidebar/sidebar-accordion/sidebar-accordion.component.html index a34db527b1fb..02f7fa9e9ebe 100644 --- a/src/main/webapp/app/shared/sidebar/sidebar-accordion/sidebar-accordion.component.html +++ b/src/main/webapp/app/shared/sidebar/sidebar-accordion/sidebar-accordion.component.html @@ -24,19 +24,37 @@ @if ((groupedData[groupKey].entityData | searchFilter: ['title', 'type'] : searchValue)?.length) {
-
- @for (sidebarItem of groupedData[groupKey].entityData | searchFilter: ['title', 'type'] : searchValue; let last = $last; track sidebarItem.id) { -
- -
+
+ @for (weekGroup of getGroupedByWeek(groupKey); track weekGroup.start?.valueOf()) { + @if (weekGroup.showDateHeader) { +
+ {{ + weekGroup.isNoDate + ? ('artemisApp.courseOverview.sidebar.noDate' | artemisTranslate) + : ('artemisApp.courseOverview.sidebar.weekRange' + | artemisTranslate + : { + start: (weekGroup.start | artemisDate: 'long-date'), + end: (weekGroup.end | artemisDate: 'long-date'), + }) + }} +
+ } +
+ @for (sidebarItem of weekGroup.items | searchFilter: ['title', 'type'] : searchValue; let last = $last; track sidebarItem.id) { +
+ +
+
+ }
}
diff --git a/src/main/webapp/app/shared/sidebar/sidebar-accordion/sidebar-accordion.component.spec.ts b/src/main/webapp/app/shared/sidebar/sidebar-accordion/sidebar-accordion.component.spec.ts index 549cc1830080..46a4ac495fd4 100644 --- a/src/main/webapp/app/shared/sidebar/sidebar-accordion/sidebar-accordion.component.spec.ts +++ b/src/main/webapp/app/shared/sidebar/sidebar-accordion/sidebar-accordion.component.spec.ts @@ -6,6 +6,7 @@ import { SidebarCardDirective } from 'app/shared/sidebar/directive/sidebar-card. import { SearchFilterPipe } from 'app/shared/pipes/search-filter.pipe'; import { SearchFilterComponent } from 'app/shared/search-filter/search-filter.component'; import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; +import { ArtemisDatePipe } from 'app/shared/pipes/artemis-date.pipe'; import { MockComponent, MockModule, MockPipe } from 'ng-mocks'; import { NgbCollapseModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; import { ActivatedRoute, RouterModule } from '@angular/router'; @@ -31,6 +32,7 @@ describe('SidebarAccordionComponent', () => { SearchFilterComponent, MockPipe(ArtemisTranslatePipe), MockComponent(SearchFilterComponent), + MockPipe(ArtemisDatePipe), ], providers: [ { provide: ActivatedRoute, useValue: new MockActivatedRoute() }, @@ -158,4 +160,10 @@ describe('SidebarAccordionComponent', () => { expect(component.totalUnreadMessagesPerGroup['future']).toBe(1); expect(component.totalUnreadMessagesPerGroup['noDate']).toBe(0); }); + + it('should use the week grouping utility for grouping items', () => { + const result = component.getGroupedByWeek('current'); + expect(result).toBeDefined(); + expect(Array.isArray(result)).toBeTruthy(); + }); }); diff --git a/src/main/webapp/app/shared/sidebar/sidebar-accordion/sidebar-accordion.component.ts b/src/main/webapp/app/shared/sidebar/sidebar-accordion/sidebar-accordion.component.ts index dee83d29a40b..1815eb7c4b5f 100644 --- a/src/main/webapp/app/shared/sidebar/sidebar-accordion/sidebar-accordion.component.ts +++ b/src/main/webapp/app/shared/sidebar/sidebar-accordion/sidebar-accordion.component.ts @@ -6,8 +6,10 @@ import { NgbCollapse } from '@ng-bootstrap/ng-bootstrap'; import { NgClass, TitleCasePipe } from '@angular/common'; import { SidebarCardDirective } from '../directive/sidebar-card.directive'; import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; +import { ArtemisDatePipe } from 'app/shared/pipes/artemis-date.pipe'; import { SearchFilterPipe } from 'app/shared/pipes/search-filter.pipe'; import { AccordionGroups, ChannelTypeIcons, CollapseState, SidebarCardElement, SidebarItemShowAlways, SidebarTypes } from 'app/shared/types/sidebar'; +import { WeekGroup, WeekGroupingUtil } from 'app/shared/util/week-grouping.util'; import { MetisConversationService } from 'app/communication/service/metis-conversation.service'; import { Subject, takeUntil } from 'rxjs'; @@ -15,7 +17,7 @@ import { Subject, takeUntil } from 'rxjs'; selector: 'jhi-sidebar-accordion', templateUrl: './sidebar-accordion.component.html', styleUrls: ['./sidebar-accordion.component.scss'], - imports: [FaIconComponent, NgbCollapse, NgClass, SidebarCardDirective, TitleCasePipe, ArtemisTranslatePipe, SearchFilterPipe], + imports: [FaIconComponent, NgbCollapse, NgClass, SidebarCardDirective, TitleCasePipe, ArtemisTranslatePipe, ArtemisDatePipe, SearchFilterPipe], }) export class SidebarAccordionComponent implements OnChanges, OnInit, OnDestroy { protected readonly Object = Object; @@ -111,4 +113,8 @@ export class SidebarAccordionComponent implements OnChanges, OnInit, OnDestroy { this.collapseState[groupCategoryKey] = !this.collapseState[groupCategoryKey]; localStorage.setItem('sidebar.accordion.collapseState.' + this.storageId + '.byCourse.' + this.courseId, JSON.stringify(this.collapseState)); } + + getGroupedByWeek(groupKey: string): WeekGroup[] { + return WeekGroupingUtil.getGroupedByWeek(this.groupedData[groupKey].entityData, groupKey, this.searchValue); + } } diff --git a/src/main/webapp/app/shared/types/sidebar.ts b/src/main/webapp/app/shared/types/sidebar.ts index 16f782646159..93a27ba0b1a9 100644 --- a/src/main/webapp/app/shared/types/sidebar.ts +++ b/src/main/webapp/app/shared/types/sidebar.ts @@ -156,6 +156,10 @@ export interface SidebarCardElement { * Set for Conversation. Will be removed after refactoring */ conversation?: ConversationDTO; + /** + * Set for Lectures, shows the start date + */ + startDate?: dayjs.Dayjs; isCurrent?: boolean; } diff --git a/src/main/webapp/app/shared/util/week-grouping.util.spec.ts b/src/main/webapp/app/shared/util/week-grouping.util.spec.ts new file mode 100644 index 000000000000..b0182bf75c2c --- /dev/null +++ b/src/main/webapp/app/shared/util/week-grouping.util.spec.ts @@ -0,0 +1,177 @@ +import { WeekGroupingUtil } from './week-grouping.util'; +import { SidebarCardElement } from '../types/sidebar'; +import dayjs from 'dayjs/esm'; + +describe('WeekGroupingUtil', () => { + it('returns a single group for noDate', () => { + const items: SidebarCardElement[] = [ + { title: 'Item 1', id: 'i1', size: 'M' }, + { title: 'Item 2', id: 'i2', size: 'M' }, + ]; + + const groups = WeekGroupingUtil.getGroupedByWeek(items, 'noDate'); + expect(groups).toHaveLength(1); + expect(groups[0].isNoDate).toBeTruthy(); + expect(groups[0].showDateHeader).toBeFalsy(); + expect(groups[0].items).toHaveLength(2); + }); + + it('returns a single group while searching (no headers)', () => { + const items: SidebarCardElement[] = [ + { title: 'Item 1', id: 'i1', size: 'M', startDate: dayjs('2024-01-01') }, + { title: 'Item 2', id: 'i2', size: 'M', startDate: dayjs('2024-01-02') }, + { title: 'Other', id: 'i3', size: 'M', startDate: dayjs('2024-01-03') }, + ]; + + const groups = WeekGroupingUtil.getGroupedByWeek(items, 'current', 'item'); + expect(groups).toHaveLength(1); + expect(groups[0].showDateHeader).toBeFalsy(); + expect(groups[0].items).toHaveLength(2); + expect(groups[0].items.map((i) => i.title)).toEqual(['Item 1', 'Item 2']); + }); + + it('searches in both title and type', () => { + const items: SidebarCardElement[] = [ + { title: 'Exercise 1', id: 'e1', size: 'M', type: 'exercise', startDate: dayjs('2024-01-01') }, + { title: 'Exercise 2', id: 'e2', size: 'M', startDate: dayjs('2024-01-02') }, + { title: 'Exercise 3', id: 'e3', size: 'M', startDate: dayjs('2024-01-03') }, + { title: 'Exercise 4', id: 'e4', size: 'M', startDate: dayjs('2024-01-04') }, + { title: 'Exercise 5', id: 'e5', size: 'M', startDate: dayjs('2024-01-05') }, + { title: 'Exercise 6', id: 'e6', size: 'M', startDate: dayjs('2024-01-07') }, + ]; + + const groups = WeekGroupingUtil.getGroupedByWeek(items, 'current', 'exercise'); + expect(groups).toHaveLength(1); + expect(groups[0].items.map((i) => i.title)).toEqual(expect.arrayContaining(['Exercise 1', 'Exercise 2', 'Exercise 3', 'Exercise 4', 'Exercise 5', 'Exercise 6'])); + }); + + it('displays correct week range title', () => { + const items: SidebarCardElement[] = [ + { title: 'L1', id: 'm1', size: 'M', startDate: dayjs('2024-01-01') }, + { title: 'L2', id: 'w1', size: 'M', startDate: dayjs('2024-01-03') }, + { title: 'L3', id: 'w1', size: 'M', startDate: dayjs('2024-01-04') }, + { title: 'L4', id: 'm2', size: 'M', startDate: dayjs('2024-01-08') }, + { title: 'L5', id: 'w2', size: 'M', startDate: dayjs('2024-01-10') }, + { title: 'L6', id: 'w2', size: 'M', startDate: dayjs('2024-01-10') }, + ]; + + const groups = WeekGroupingUtil.getGroupedByWeek(items, 'current'); + expect(groups).toHaveLength(2); + + // First group should be Week 2 (Jan 8-14) + expect(groups[0].start!.format('DD MMM YYYY')).toBe('07 Jan 2024'); + expect(groups[0].end!.format('DD MMM YYYY')).toBe('13 Jan 2024'); + + // Second group should be Week 1 (Jan 1-7) + expect(groups[1].start!.format('DD MMM YYYY')).toBe('31 Dec 2023'); + expect(groups[1].end!.format('DD MMM YYYY')).toBe('06 Jan 2024'); + }); + + it('sorts items inside each group by date (descending)', () => { + const items: SidebarCardElement[] = ['06', '05', '04', '03', '02', '01'].map((d) => ({ + title: `Item ${d}`, + id: `d${d}`, + size: 'M', + startDate: dayjs(`2024-01-${d}`), + })); + + const [firstGroup] = WeekGroupingUtil.getGroupedByWeek(items, 'current'); + expect(firstGroup.items.map((i) => i.title)).toEqual(['Item 06', 'Item 05', 'Item 04', 'Item 03', 'Item 02', 'Item 01']); + }); + + it('handles different group keys correctly', () => { + const currentItems: SidebarCardElement[] = [ + { title: 'Current 1', id: 'c1', size: 'M', startDate: dayjs() }, + { title: 'Current 2', id: 'c2', size: 'M', startDate: dayjs().add(1, 'day') }, + { title: 'Current 3', id: 'c3', size: 'M', startDate: dayjs().add(1, 'day') }, + { title: 'Current 4', id: 'c4', size: 'M', startDate: dayjs().add(1, 'day') }, + { title: 'Current 5', id: 'c5', size: 'M', startDate: dayjs().add(1, 'day') }, + { title: 'Current 6', id: 'c6', size: 'M', startDate: dayjs().add(1, 'day') }, + ]; + + const futureItems: SidebarCardElement[] = [ + { title: 'Future 1', id: 'f1', size: 'M', startDate: dayjs().add(1, 'month') }, + { title: 'Future 2', id: 'f2', size: 'M', startDate: dayjs().add(2, 'month') }, + ]; + + const noDateItems: SidebarCardElement[] = [ + { title: 'No Date 1', id: 'n1', size: 'M' }, + { title: 'No Date 2', id: 'n2', size: 'M' }, + ]; + + const currentGroups = WeekGroupingUtil.getGroupedByWeek(currentItems, 'current'); + expect(currentGroups).toHaveLength(1); + expect(currentGroups[0].showDateHeader).toBeTruthy(); + + const futureGroups = WeekGroupingUtil.getGroupedByWeek(futureItems, 'future'); + expect(futureGroups).toHaveLength(1); + expect(futureGroups[0].showDateHeader).toBeFalsy(); + + const noDateGroups = WeekGroupingUtil.getGroupedByWeek(noDateItems, 'noDate'); + expect(noDateGroups).toHaveLength(1); + expect(noDateGroups[0].isNoDate).toBeTruthy(); + expect(noDateGroups[0].showDateHeader).toBeFalsy(); + }); + + it('handles mixed dates within each group key', () => { + const baseDate = dayjs('2025-01-01'); + + const items: SidebarCardElement[] = [ + { title: 'Week 1', id: 'c1', size: 'M', startDate: baseDate.add(0, 'day') }, + { title: 'Week 2', id: 'c2', size: 'M', startDate: baseDate.add(1, 'day') }, + { title: 'Week 3', id: 'c3', size: 'M', startDate: baseDate.add(2, 'day') }, + { title: 'Week 4', id: 'c4', size: 'M', startDate: baseDate.add(3, 'day') }, + { title: 'Week 5', id: 'c5', size: 'M', startDate: baseDate.add(4, 'day') }, + { title: 'Week 6', id: 'c6', size: 'M', startDate: baseDate.add(5, 'day') }, + { title: 'Week 7', id: 'c7', size: 'M' }, + { title: 'Week 8', id: 'c8', size: 'M' }, + { title: 'Week 9', id: 'c9', size: 'M' }, + ]; + + const currentGroups = WeekGroupingUtil.getGroupedByWeek(items, 'future'); + expect(currentGroups).toHaveLength(3); + + expect(currentGroups[0].showDateHeader).toBeTruthy(); + expect(currentGroups[0].isNoDate).toBeFalsy(); + expect(currentGroups[0].items[0].title).toBe('Week 6'); + + expect(currentGroups[1].showDateHeader).toBeTruthy(); + expect(currentGroups[1].isNoDate).toBeFalsy(); + expect(currentGroups[1].items[0].title).toBe('Week 4'); + + expect(currentGroups[2].showDateHeader).toBeTruthy(); + expect(currentGroups[2].isNoDate).toBeTruthy(); + expect(currentGroups[2].items[0].title).toBe('Week 7'); + }); + + it('sorts groups correctly within the same year', () => { + const items: SidebarCardElement[] = [ + // March items + { title: 'March 1', id: 'm1', size: 'M', startDate: dayjs('2025-03-01') }, + { title: 'March 15', id: 'm2', size: 'M', startDate: dayjs('2025-03-15') }, + // January items + { title: 'January 1', id: 'j1', size: 'M', startDate: dayjs('2025-01-01') }, + { title: 'January 15', id: 'j2', size: 'M', startDate: dayjs('2025-01-15') }, + // February items + { title: 'February 1', id: 'f1', size: 'M', startDate: dayjs('2025-02-01') }, + { title: 'February 15', id: 'f2', size: 'M', startDate: dayjs('2025-02-15') }, + // No date items + { title: 'No Date 1', id: 'n1', size: 'M' }, + { title: 'No Date 2', id: 'n2', size: 'M' }, + ]; + + const groups = WeekGroupingUtil.getGroupedByWeek(items, 'current'); + + // Should have 7 groups (2 weeks per month for 3 months + 1 no-date group) + expect(groups).toHaveLength(7); + + // Groups should be ordered by date (newest first) with no-date group at the end + expect(groups[0].start!.format('YYYY-MM-DD')).toBe('2025-03-09'); // March week 2 + expect(groups[1].start!.format('YYYY-MM-DD')).toBe('2025-02-23'); // March week 1 + expect(groups[2].start!.format('YYYY-MM-DD')).toBe('2025-02-09'); // February week 2 + expect(groups[3].start!.format('YYYY-MM-DD')).toBe('2025-01-26'); // February week 1 + expect(groups[4].start!.format('YYYY-MM-DD')).toBe('2025-01-12'); // January week 2 + expect(groups[5].start!.format('YYYY-MM-DD')).toBe('2024-12-29'); // January week 1 + expect(groups[6].isNoDate).toBeTruthy(); + }); +}); diff --git a/src/main/webapp/app/shared/util/week-grouping.util.ts b/src/main/webapp/app/shared/util/week-grouping.util.ts new file mode 100644 index 000000000000..a434b2611779 --- /dev/null +++ b/src/main/webapp/app/shared/util/week-grouping.util.ts @@ -0,0 +1,132 @@ +import dayjs from 'dayjs/esm'; +import { SidebarCardElement } from 'app/shared/types/sidebar'; + +export interface WeekGroup { + isNoDate: boolean; + start?: dayjs.Dayjs; + end?: dayjs.Dayjs; + items: SidebarCardElement[]; + showDateHeader: boolean; +} + +export const NO_DATE_KEY = 'artemisApp.courseOverview.sidebar.noDate'; +export const MIN_ITEMS_TO_GROUP_BY_WEEK = 5; + +export class WeekGroupingUtil { + /** + * Extracts the most relevant date from a sidebar element. + * + * @param item - The sidebar element to extract the date from + * @returns The extracted date, or undefined if no date is found + */ + static getDateFromItem(item: SidebarCardElement): dayjs.Dayjs | undefined { + const date = item.exercise?.dueDate ?? item.startDateWithTime ?? item.startDate; + return date ? dayjs(date) : undefined; + } + + /** + * Creates a unique key for a week based on its start and end dates. + * + * @param date - The date to get the week key for + * @returns A unique string identifier for the week + */ + static getWeekKey(date: dayjs.Dayjs): string { + const startOfWeek = date.startOf('week'); + const endOfWeek = date.endOf('week'); + return `${startOfWeek.year()} - ${startOfWeek.format('DD MMM YYYY')} - ${endOfWeek.format('DD MMM YYYY')}`; + } + + /** + * Compares two dates for sorting purposes. + * Undefined dates are sorted to the end. + * + * @param a - First date to compare + * @param b - Second date to compare + * @returns Negative if a < b, positive if a > b, 0 if equal + */ + static compareDates(a?: dayjs.Dayjs, b?: dayjs.Dayjs): number { + if (!a && !b) { + return 0; + } + if (!a) { + return 1; + } + if (!b) { + return -1; + } + return b.valueOf() - a.valueOf(); + } + + /** + * Groups sidebar items into ISO‑weeks (or returns them as‑is for special groups). + * + * @param items - The items to group + * @param groupKey - Name of the high‑level group ("lecture", "exercise", …) + * @param searchValue - Optional search string to filter items + * @returns Array of WeekGroup objects containing the grouped items + */ + static getGroupedByWeek(items: SidebarCardElement[], groupKey: string, searchValue = ''): WeekGroup[] { + // Filter items based on search value if provided + const filtered = searchValue + ? items.filter((i) => { + const title = i.title?.toLowerCase() ?? ''; + const type = i.type?.toLowerCase() ?? ''; + return title.includes(searchValue.toLowerCase()) || type.includes(searchValue.toLowerCase()); + }) + : items; + + // Return single group without headers for special cases + if (groupKey === 'real' || groupKey === 'test' || groupKey === 'attempt' || !!searchValue || filtered.length <= MIN_ITEMS_TO_GROUP_BY_WEEK) { + return [{ isNoDate: true, items: filtered, showDateHeader: false }]; + } + + // Group items by week + const weekMap = new Map(); + for (const item of filtered) { + const date = this.getDateFromItem(item); + const key = date ? this.getWeekKey(date) : NO_DATE_KEY; + const bucket = weekMap.get(key) ?? []; + bucket.push(item); + weekMap.set(key, bucket); + } + + // Sort items within each week by date + for (const list of weekMap.values()) { + list.sort((a, b) => this.compareDates(this.getDateFromItem(a), this.getDateFromItem(b))); + } + + // Convert week map to WeekGroup array + const groups: WeekGroup[] = Array.from(weekMap.entries()).map(([key, list]) => { + if (key === NO_DATE_KEY) { + return { + isNoDate: true, + items: list, + showDateHeader: true, + }; + } + + const [, startStr, endStr] = key.split(' - '); + return { + isNoDate: false, + start: dayjs(startStr, 'DD MMM YYYY'), + end: dayjs(endStr, 'DD MMM YYYY'), + items: list, + showDateHeader: true, + }; + }); + + // Sort groups: dated groups first (by year and date), then no-date groups + return groups.sort((a, b) => { + if (a.isNoDate) { + return 1; + } + if (b.isNoDate) { + return -1; + } + if (a.start!.year() !== b.start!.year()) { + return b.start!.year() - a.start!.year(); + } + return b.start!.valueOf() - a.start!.valueOf(); + }); + } +} diff --git a/src/main/webapp/i18n/de/student-dashboard.json b/src/main/webapp/i18n/de/student-dashboard.json index 8a9b4c1d9638..1406eee8c4a1 100644 --- a/src/main/webapp/i18n/de/student-dashboard.json +++ b/src/main/webapp/i18n/de/student-dashboard.json @@ -90,7 +90,8 @@ "directMessages": "Direktnachrichten", "filterConversationPlaceholder": "Konversationen filtern", "setChannelAsRead": "Alle Kanäle als gelesen markieren", - "recents": "Kürzliches" + "recents": "Kürzliches", + "weekRange": "{{ start }} - {{ end }}" }, "menu": { "exercises": "Aufgaben", diff --git a/src/main/webapp/i18n/en/student-dashboard.json b/src/main/webapp/i18n/en/student-dashboard.json index 3b9e92bb7c9e..14bc8abc617b 100644 --- a/src/main/webapp/i18n/en/student-dashboard.json +++ b/src/main/webapp/i18n/en/student-dashboard.json @@ -90,7 +90,8 @@ "directMessages": "Direct Messages", "filterConversationPlaceholder": "Filter conversations", "setChannelAsRead": "Mark all channels as read", - "recents": "Recents" + "recents": "Recents", + "weekRange": "{{ start }} - {{ end }}" }, "menu": { "exercises": "Exercises",