diff --git a/src/app/services/localStorageService.ts b/src/app/services/localStorageService.ts new file mode 100644 index 00000000000..08f2cf8926e --- /dev/null +++ b/src/app/services/localStorageService.ts @@ -0,0 +1,40 @@ +import { Injectable } from '@angular/core'; + +@Injectable({ + providedIn: 'root' +}) +export class LocalStorageService { + setItem(key: string, value: any): void { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch (e) { + console.error('Error saving to local storage', e); + } + } + + getItem(key: string): any { + try { + const item = localStorage.getItem(key); + return item ? JSON.parse(item) : null; + } catch (e) { + console.error('Error reading from local storage', e); + return null; + } + } + + removeItem(key: string): void { + try { + localStorage.removeItem(key); + } catch (e) { + console.error('Error removing from local storage', e); + } + } + + clear(): void { + try { + localStorage.clear(); + } catch (e) { + console.error('Error clearing local storage', e); + } + } +} diff --git a/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.html b/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.html index dffde8abbd6..f7c1a5d0092 100644 --- a/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.html +++ b/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.html @@ -72,6 +72,18 @@ /> + } @else if (component?.type === 'OpenResponse') { + + + + + } } diff --git a/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.spec.ts b/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.spec.ts index 3efa31d2f3a..7d2349d79a5 100644 --- a/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.spec.ts +++ b/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.spec.ts @@ -12,6 +12,7 @@ import { CRaterService } from '../../../services/cRaterService'; import { TeacherDataService } from '../../../services/teacherDataService'; import { SummaryService } from '../../../components/summary/summaryService'; import { PeerGroupButtonComponent } from '../peer-group-button/peer-group-button.component'; +import { ProjectService } from '../../../services/projectService'; let component: ComponentSummaryComponent; let fixture: ComponentFixture; @@ -31,6 +32,7 @@ describe('ComponentSummaryComponent', () => { AnnotationService, ComponentServiceLookupService, CRaterService, + ProjectService, SummaryService ), MockProvider(TeacherDataService, { diff --git a/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.ts b/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.ts index 2fa2c1ff40b..7c8d98686f8 100644 --- a/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.ts +++ b/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.ts @@ -14,6 +14,8 @@ import { IdeasSummaryComponent } from '../../../directives/teacher-summary-displ import { MatchSummaryDisplayComponent } from '../../../directives/teacher-summary-display/match-summary-display/match-summary-display.component'; import { MatCardModule } from '@angular/material/card'; import { CRaterService } from '../../../services/cRaterService'; +import { OpenResponseSummaryDisplayComponent } from '../../../directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component'; +import { ProjectService } from '../../../services/projectService'; @Component({ imports: [ @@ -22,6 +24,7 @@ import { CRaterService } from '../../../services/cRaterService'; MatCardModule, MatchSummaryDisplayComponent, MilestoneReportButtonComponent, + OpenResponseSummaryDisplayComponent, PeerGroupButtonComponent, TeacherSummaryDisplayComponent ], @@ -48,6 +51,7 @@ export class ComponentSummaryComponent { private componentServiceLookupService: ComponentServiceLookupService, private cRaterService: CRaterService, private dataService: TeacherDataService, + private projectService: ProjectService, private summaryService: SummaryService ) {} @@ -79,6 +83,9 @@ export class ComponentSummaryComponent { (this.hasScoresSummary && this.hasScoreAnnotation) || this.hasIdeaRubricData || this.component?.type === 'Match'; + if (this.component?.type === 'OpenResponse') { + this.hasSummaryData = this.projectService.getProject().ai?.enabled; + } } private setSource(): void { diff --git a/src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.html b/src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.html new file mode 100644 index 00000000000..f4bda35f709 --- /dev/null +++ b/src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.html @@ -0,0 +1,24 @@ +@if (hasStudentResponses) { +
+
+ + @if (generatingSummary) { + + } +
+ @if (newSummaryAvailable) { + *New responses since last summary + } +
+ @if (summary) { + +
+ Summary generated {{ summaryDate | date: 'short' }} from + {{ getLatestPeriodComponentStates().length }} responses +
+ } +} @else { +
No student responses
+} diff --git a/src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.spec.ts b/src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.spec.ts new file mode 100644 index 00000000000..65290c15839 --- /dev/null +++ b/src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.spec.ts @@ -0,0 +1,377 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { OpenResponseSummaryDisplayComponent } from './open-response-summary-display.component'; +import { MockComponent, MockProviders } from 'ng-mocks'; +import { AnnotationService } from '../../../services/annotationService'; +import { ConfigService } from '../../../services/configService'; +import { CRaterService } from '../../../services/cRaterService'; +import { ProjectService } from '../../../services/projectService'; +import { SummaryService } from '../../../components/summary/summaryService'; +import { AwsBedRockService } from '../../../../../app/chatbot/awsBedRock.service'; +import { TeacherDataService } from '../../../services/teacherDataService'; +import { LocalStorageService } from '../../../../../app/services/localStorageService'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { DataService } from '../../../../../app/services/data.service'; +import { MarkdownComponent, MarkdownService } from 'ngx-markdown'; + +describe('OpenResponseSummaryDisplayComponent', () => { + let component: OpenResponseSummaryDisplayComponent; + let fixture: ComponentFixture; + let awsBedRockService: AwsBedRockService; + let localStorageService: LocalStorageService; + let dataService: TeacherDataService; + let projectService: ProjectService; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [OpenResponseSummaryDisplayComponent, MockComponent(MarkdownComponent)], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + { provide: DataService, useExisting: TeacherDataService }, + MockProviders( + AnnotationService, + AwsBedRockService, + ConfigService, + CRaterService, + LocalStorageService, + MarkdownService, + ProjectService, + SummaryService, + TeacherDataService + ) + ] + }).compileComponents(); + + awsBedRockService = TestBed.inject(AwsBedRockService); + localStorageService = TestBed.inject(LocalStorageService); + dataService = TestBed.inject(TeacherDataService) as TeacherDataService; + projectService = TestBed.inject(ProjectService); + + spyOn(projectService, 'getComponent').and.returnValue({ + id: 'component1', + type: 'OpenResponse', + prompt: 'What is your opinion on climate change?' + } as any); + + fixture = TestBed.createComponent(OpenResponseSummaryDisplayComponent); + component = fixture.componentInstance; + component.nodeId = 'node1'; + component.componentId = 'component1'; + component.periodId = 1; + }); + + describe('ngOnInit', () => { + it('should call renderDisplay', () => { + spyOn(component as any, 'renderDisplay'); + component.ngOnInit(); + expect((component as any).renderDisplay).toHaveBeenCalled(); + }); + }); + + describe('renderDisplay', () => { + it('should set hasStudentResponses to false when no component states exist', () => { + spyOn(dataService, 'getComponentStatesByComponentId').and.returnValue([]); + component.ngOnInit(); + fixture.detectChanges(); + expect(component['hasStudentResponses']).toBe(false); + }); + + it('should set hasStudentResponses to true when component states exist', () => { + spyOn(dataService, 'getComponentStatesByComponentId').and.returnValue(getComponentStates()); + component.ngOnInit(); + fixture.detectChanges(); + expect(component['hasStudentResponses']).toBe(true); + }); + + it('should load summary from localStorage if it exists', () => { + spyOn(dataService, 'getComponentStatesByComponentId').and.returnValue(getComponentStates()); + const savedSummary = 'This is a saved summary'; + spyOn(localStorageService, 'getItem').and.returnValues(savedSummary, 1000); + fixture.detectChanges(); + expect(component['summary']).toBe(savedSummary); + }); + + it('should set newSummaryAvailable to true when responses are newer than summary', () => { + const componentStates = getComponentStates(); + spyOn(dataService, 'getComponentStatesByComponentId').and.returnValue(componentStates); + const oldTimestamp = 1000; + spyOn(localStorageService, 'getItem').and.returnValues('Old summary', oldTimestamp); + fixture.detectChanges(); + expect(component['newSummaryAvailable']).toBe(true); + }); + + it('should set newSummaryAvailable to false when summary is newer than responses', () => { + const componentStates = getComponentStates(); + spyOn(dataService, 'getComponentStatesByComponentId').and.returnValue(componentStates); + const futureTimestamp = Date.now() + 100000; + spyOn(localStorageService, 'getItem').and.returnValues('Recent summary', futureTimestamp); + component.ngOnInit(); + fixture.detectChanges(); + expect(component['newSummaryAvailable']).toBe(false); + }); + }); + + describe('getLatestPeriodComponentStates', () => { + it('should filter component states by period ID', () => { + const componentStates = getComponentStates(); + spyOn(dataService, 'getComponentStatesByComponentId').and.returnValue(componentStates); + component.periodId = 1; + const result = component['getLatestPeriodComponentStates'](); + expect(result.every((state) => state.periodId === 1)).toBe(true); + }); + + it('should return all component states when periodId is -1', () => { + const componentStates = [ + ...getComponentStates(), + { + id: 4, + componentId: 'component1', + nodeId: 'node1', + periodId: 2, + runId: 1, + serverSaveTime: 4000, + studentData: { response: 'Response from period 2' }, + workgroupId: 4 + } + ]; + spyOn(dataService, 'getComponentStatesByComponentId').and.returnValue(componentStates); + component.periodId = -1; + const result = component['getLatestPeriodComponentStates'](); + expect(result.length).toBe(4); + }); + + it('should return only the latest state per workgroup', () => { + const componentStates = [ + ...getComponentStates(), + { + id: 4, + componentId: 'component1', + nodeId: 'node1', + periodId: 1, + runId: 1, + serverSaveTime: 5000, + studentData: { response: 'Updated response from workgroup 1' }, + workgroupId: 1 + } + ]; + spyOn(dataService, 'getComponentStatesByComponentId').and.returnValue(componentStates); + component.periodId = 1; + const result = component['getLatestPeriodComponentStates'](); + const workgroup1States = result.filter((state) => state.workgroupId === 1); + expect(workgroup1States.length).toBe(1); + expect(workgroup1States[0].serverSaveTime).toBe(5000); + }); + }); + + describe('generateSummary', () => { + beforeEach(() => { + spyOn(dataService, 'getComponentStatesByComponentId').and.returnValue(getComponentStates()); + component.ngOnInit(); + fixture.detectChanges(); + }); + + it('should call awsBedRockService with correct system prompt', async () => { + const sendMessageSpy = spyOn(awsBedRockService, 'sendMessage').and.returnValue( + Promise.resolve('Generated summary') + ); + await component['generateSummary'](); + const messages = sendMessageSpy.calls.mostRecent().args[0]; + expect(messages[0].role).toBe('system'); + expect(messages[0].content).toContain('What is your opinion on climate change?'); + }); + + it('should call awsBedRockService with student responses', async () => { + const sendMessageSpy = spyOn(awsBedRockService, 'sendMessage').and.returnValue( + Promise.resolve('Generated summary') + ); + await component['generateSummary'](); + const messages = sendMessageSpy.calls.mostRecent().args[0]; + expect(messages[1].role).toBe('user'); + expect(messages[1].content).toContain(''); + expect(messages[1].content).toContain('Climate change is real'); + }); + + it('should save summary to localStorage', async () => { + const generatedSummary = 'This is a generated summary'; + spyOn(awsBedRockService, 'sendMessage').and.returnValue(Promise.resolve(generatedSummary)); + const setItemSpy = spyOn(localStorageService, 'setItem'); + await component['generateSummary'](); + expect(setItemSpy).toHaveBeenCalledWith( + 'openResponseSummary-1-node1-component1', + generatedSummary + ); + }); + + it('should save timestamp to localStorage', async () => { + spyOn(awsBedRockService, 'sendMessage').and.returnValue(Promise.resolve('Generated summary')); + const setItemSpy = spyOn(localStorageService, 'setItem'); + const beforeTime = new Date().getTime(); + await component['generateSummary'](); + const afterTime = new Date().getTime(); + const timestampCall = setItemSpy.calls + .all() + .find((call) => call.args[0].includes('timestamp')); + expect(timestampCall).toBeDefined(); + expect(timestampCall.args[1]).toBeGreaterThanOrEqual(beforeTime); + expect(timestampCall.args[1]).toBeLessThanOrEqual(afterTime); + }); + + it('should set generatingSummary to false after completion', async () => { + spyOn(awsBedRockService, 'sendMessage').and.returnValue(Promise.resolve('Generated summary')); + await component['generateSummary'](); + expect(component['generatingSummary']).toBe(false); + }); + + it('should set newSummaryAvailable to false after generation', async () => { + component['newSummaryAvailable'] = true; + spyOn(awsBedRockService, 'sendMessage').and.returnValue(Promise.resolve('Generated summary')); + await component['generateSummary'](); + expect(component['newSummaryAvailable']).toBe(false); + }); + + it('should update summary property', async () => { + const generatedSummary = 'This is a generated summary'; + spyOn(awsBedRockService, 'sendMessage').and.returnValue(Promise.resolve(generatedSummary)); + await component['generateSummary'](); + expect(component['summary']).toBe(generatedSummary); + }); + }); + + describe('getStudentResponses', () => { + it('should format student responses with XML tags', () => { + spyOn(dataService, 'getComponentStatesByComponentId').and.returnValue(getComponentStates()); + component.ngOnInit(); + const responses = component['getStudentResponses'](); + expect(responses).toContain('Climate change is real'); + expect(responses).toContain('We need to act now'); + expect(responses).toContain('Renewable energy is the future'); + }); + + it('should concatenate all responses', () => { + spyOn(dataService, 'getComponentStatesByComponentId').and.returnValue(getComponentStates()); + component.ngOnInit(); + const responses = component['getStudentResponses'](); + const responseCount = (responses.match(//g) || []).length; + expect(responseCount).toBe(3); + }); + }); + + describe('template rendering', () => { + it('should display "No student responses" when hasStudentResponses is false', () => { + spyOn(dataService, 'getComponentStatesByComponentId').and.returnValue([]); + component.ngOnInit(); + fixture.detectChanges(); + const compiled = fixture.nativeElement; + expect(compiled.textContent).toContain('No student responses'); + }); + + it('should display generate button when hasStudentResponses is true', () => { + spyOn(dataService, 'getComponentStatesByComponentId').and.returnValue(getComponentStates()); + component.ngOnInit(); + fixture.detectChanges(); + const button = fixture.nativeElement.querySelector('button'); + expect(button).toBeTruthy(); + expect(button.textContent).toContain('Generate Class Summary'); + }); + + it('should disable generate button when generatingSummary is true', () => { + spyOn(dataService, 'getComponentStatesByComponentId').and.returnValue(getComponentStates()); + component.ngOnInit(); + component['generatingSummary'] = true; + fixture.detectChanges(); + const button = fixture.nativeElement.querySelector('button'); + expect(button.disabled).toBe(true); + }); + + it('should display "New responses since last summary" when newSummaryAvailable is true', () => { + spyOn(dataService, 'getComponentStatesByComponentId').and.returnValue(getComponentStates()); + const oldTimestamp = 1000; + spyOn(localStorageService, 'getItem') + .withArgs('openResponseSummary-1-node1-component1') + .and.returnValue('Old summary') + .withArgs('openResponseSummary-timestamp-1-node1-component1') + .and.returnValue(oldTimestamp); + component.ngOnInit(); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain('New responses since last summary'); + }); + + it('should display spinner when generatingSummary is true', () => { + spyOn(dataService, 'getComponentStatesByComponentId').and.returnValue(getComponentStates()); + component.ngOnInit(); + component['generatingSummary'] = true; + fixture.detectChanges(); + const spinner = fixture.nativeElement.querySelector('mat-spinner'); + expect(spinner).toBeTruthy(); + }); + + it('should display summary when summary exists', () => { + spyOn(dataService, 'getComponentStatesByComponentId').and.returnValue(getComponentStates()); + const savedSummary = 'This is a saved summary'; + spyOn(localStorageService, 'getItem') + .withArgs('openResponseSummary-1-node1-component1') + .and.returnValue(savedSummary) + .withArgs('openResponseSummary-timestamp-1-node1-component1') + .and.returnValue(Date.now() + 100000); + component.ngOnInit(); + fixture.detectChanges(); + const markdown = fixture.nativeElement.querySelector('markdown'); + expect(markdown).toBeTruthy(); + }); + + it('should display response count when summary exists', () => { + spyOn(dataService, 'getComponentStatesByComponentId').and.returnValue(getComponentStates()); + const savedSummary = 'This is a saved summary'; + spyOn(localStorageService, 'getItem') + .withArgs('openResponseSummary-1-node1-component1') + .and.returnValue(savedSummary) + .withArgs('openResponseSummary-timestamp-1-node1-component1') + .and.returnValue(Date.now() + 100000); + component.ngOnInit(); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain('3 responses'); + }); + }); +}); + +function getComponentStates(): any[] { + return [ + { + id: 1, + componentId: 'component1', + nodeId: 'node1', + periodId: 1, + runId: 1, + serverSaveTime: 1000, + studentData: { + response: 'Climate change is real' + }, + workgroupId: 1 + }, + { + id: 2, + componentId: 'component1', + nodeId: 'node1', + periodId: 1, + runId: 1, + serverSaveTime: 2000, + studentData: { + response: 'We need to act now' + }, + workgroupId: 2 + }, + { + id: 3, + componentId: 'component1', + nodeId: 'node1', + periodId: 1, + runId: 1, + serverSaveTime: 3000, + studentData: { + response: 'Renewable energy is the future' + }, + workgroupId: 3 + } + ]; +} diff --git a/src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.ts b/src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.ts new file mode 100644 index 00000000000..84c334adc63 --- /dev/null +++ b/src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.ts @@ -0,0 +1,98 @@ +import { DatePipe } from '@angular/common'; +import { Component, inject } from '@angular/core'; +import { TeacherSummaryDisplayComponent } from '../teacher-summary-display.component'; +import { MatButton } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; +import { AwsBedRockService } from '../../../../../app/chatbot/awsBedRock.service'; +import { ChatMessage } from '../../../../../app/chatbot/chat'; +import { TeacherDataService } from '../../../services/teacherDataService'; +import { MatProgressSpinner } from '@angular/material/progress-spinner'; +import { LocalStorageService } from '../../../../../app/services/localStorageService'; +import { MarkdownComponent } from 'ngx-markdown'; + +@Component({ + imports: [DatePipe, MarkdownComponent, MatButton, MatIcon, MatProgressSpinner], + selector: 'open-response-summary', + templateUrl: './open-response-summary-display.component.html' +}) +export class OpenResponseSummaryDisplayComponent extends TeacherSummaryDisplayComponent { + protected awsBedRockService: AwsBedRockService = inject(AwsBedRockService); + protected generatingSummary: boolean = false; + protected hasStudentResponses: boolean = false; + private localStorageService: LocalStorageService = inject(LocalStorageService); + protected newSummaryAvailable: boolean = false; + protected summary: string; + protected summaryDate: Date; + private summaryTimestamp: number; + + ngOnInit(): void { + this.renderDisplay(); + } + + protected renderDisplay(): void { + super.renderDisplay(); + const latestPeriodComponentStates = this.getLatestPeriodComponentStates(); + this.hasStudentResponses = latestPeriodComponentStates.length > 0; + if (!this.hasStudentResponses) { + return; + } + this.summary = + this.localStorageService.getItem( + `openResponseSummary-${this.periodId}-${this.nodeId}-${this.componentId}` + ) || ''; + this.summaryTimestamp = + this.localStorageService.getItem( + `openResponseSummary-timestamp-${this.periodId}-${this.nodeId}-${this.componentId}` + ) || 0; + this.summaryDate = new Date(this.summaryTimestamp); + const lastResponseTime = latestPeriodComponentStates.reduce((max, state) => { + return Math.max(max, state.serverSaveTime); + }, 0); + this.newSummaryAvailable = + this.summaryTimestamp > 0 && lastResponseTime > this.summaryTimestamp; + } + + protected getLatestPeriodComponentStates(): any[] { + return (this.dataService as TeacherDataService) + .getComponentStatesByComponentId(this.componentId) + .filter((state) => state.periodId === this.periodId || this.periodId === -1) + .sort((a, b) => a.serverSaveTime - b.serverSaveTime) + .reduceRight( + (soFar, currentState) => + soFar.find((state) => state.workgroupId === currentState.workgroupId) + ? soFar + : soFar.concat(currentState), + [] + ); + } + + protected async generateSummary(): Promise { + this.generatingSummary = true; + const prompt = this.projectService.getComponent(this.nodeId, this.componentId).prompt; + const systemPrompt = `You are a teacher who is summarizing student responses to the following question: "${prompt}". + Each student response is in the format: Response. + In the same language as the question, provide a summary of the responses in 100 words or less.`; + const messages = [ + new ChatMessage('system', systemPrompt, this.nodeId), + new ChatMessage('user', this.getStudentResponses(), this.nodeId) + ]; + this.summary = await this.awsBedRockService.sendMessage(messages); + this.localStorageService.setItem( + `openResponseSummary-${this.periodId}-${this.nodeId}-${this.componentId}`, + this.summary + ); + this.localStorageService.setItem( + `openResponseSummary-timestamp-${this.periodId}-${this.nodeId}-${this.componentId}`, + new Date().getTime() + ); + this.generatingSummary = false; + this.newSummaryAvailable = false; + } + + private getStudentResponses(): string { + return this.getLatestPeriodComponentStates().reduce( + (soFar, state) => `${soFar}${state.studentData.response}`, + '' + ); + } +} diff --git a/src/messages.xlf b/src/messages.xlf index 7ec3d98f169..f0bae20cbf5 100644 --- a/src/messages.xlf +++ b/src/messages.xlf @@ -21930,6 +21930,34 @@ If this problem continues, let your teacher know and move on to the next activit 58,61 + + Generate Class Summary + + src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.html + 5,7 + + + + *New responses since last summary + + src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.html + 12,16 + + + + Summary generated from responses + + src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.html + 18,22 + + + + No student responses + + src/assets/wise5/directives/teacher-summary-display/open-response-summary-display/open-response-summary-display.component.html + 23,25 + + The student will see a graph of their individual data here.