- @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",