From 8b7368b414821369fb1508866009e06af36d8ff1 Mon Sep 17 00:00:00 2001 From: bayang Date: Sun, 15 May 2022 14:41:55 +0200 Subject: [PATCH] feat: add stats --- src/jelu-ui/package-lock.json | 27 ++++ src/jelu-ui/package.json | 2 + src/jelu-ui/src/components/AdminBase.vue | 1 + src/jelu-ui/src/components/UserStats.vue | 152 ++++++++++++++++++ src/jelu-ui/src/locales/en.json | 8 +- src/jelu-ui/src/locales/fr.json | 8 +- src/jelu-ui/src/model/YearStats.ts | 12 ++ src/jelu-ui/src/router.ts | 1 + src/jelu-ui/src/services/DataService.ts | 51 ++++++ .../controllers/ReadingEventsController.kt | 78 +++++++++ .../bayang/jelu/dao/ReadingEventRepository.kt | 22 +++ .../io/github/bayang/jelu/dto/ReadStatsDto.kt | 14 ++ .../jelu/service/ReadingEventService.kt | 4 + 13 files changed, 378 insertions(+), 2 deletions(-) create mode 100644 src/jelu-ui/src/components/UserStats.vue create mode 100644 src/jelu-ui/src/model/YearStats.ts create mode 100644 src/main/kotlin/io/github/bayang/jelu/dto/ReadStatsDto.kt diff --git a/src/jelu-ui/package-lock.json b/src/jelu-ui/package-lock.json index 8765d26e..566b1e80 100644 --- a/src/jelu-ui/package-lock.json +++ b/src/jelu-ui/package-lock.json @@ -19,6 +19,7 @@ "@vueuse/core": "8.2.6", "@vueuse/router": "8.2.6", "axios": "0.26.1", + "chart.js": "^3.7.1", "daisyui": "2.14.3", "dayjs": "1.11.0", "floating-vue": "^2.0.0-beta.15", @@ -26,6 +27,7 @@ "theme-change": "^2.0.2", "vue": "3.2.33", "vue-avatar-sdh": "^1.0.3", + "vue-chartjs": "^4.1.0", "vue-i18n": "^9.1.9", "vue-router": "4.0.14", "vuejs-sidebar-menu": "^1.0.0", @@ -1323,6 +1325,11 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chart.js": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-3.7.1.tgz", + "integrity": "sha512-8knRegQLFnPQAheZV8MjxIXc5gQEfDFD897BJgv/klO/vtIyFFmgMXrNfgrXpbTr/XbTturxRgxIXx/Y+ASJBA==" + }, "node_modules/color": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/color/-/color-4.2.1.tgz", @@ -3561,6 +3568,15 @@ "vue": "^3.0.5" } }, + "node_modules/vue-chartjs": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-4.1.0.tgz", + "integrity": "sha512-lHOs5vjb9dDdXz/0TbQWjJuL1/KOLT1tlvo+6b1M+JyyIQjeAf8K9IVsUhx8fdSIR4QD9CBELN/cYgYwKdiJ7g==", + "peerDependencies": { + "chart.js": "^3.7.0", + "vue": "^3.0.0-0 || ^2.6.0" + } + }, "node_modules/vue-eslint-parser": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-8.0.1.tgz", @@ -4660,6 +4676,11 @@ "supports-color": "^7.1.0" } }, + "chart.js": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-3.7.1.tgz", + "integrity": "sha512-8knRegQLFnPQAheZV8MjxIXc5gQEfDFD897BJgv/klO/vtIyFFmgMXrNfgrXpbTr/XbTturxRgxIXx/Y+ASJBA==" + }, "color": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/color/-/color-4.2.1.tgz", @@ -6154,6 +6175,12 @@ "integrity": "sha512-Ab/8FaFMLhffmWqVFrnOPsvnpmnqVNhYub0vCp3Er45lXDqWoy8JtVYaIDbXhPxS3XCZILc/K7kxvocrAHY36w==", "requires": {} }, + "vue-chartjs": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-4.1.0.tgz", + "integrity": "sha512-lHOs5vjb9dDdXz/0TbQWjJuL1/KOLT1tlvo+6b1M+JyyIQjeAf8K9IVsUhx8fdSIR4QD9CBELN/cYgYwKdiJ7g==", + "requires": {} + }, "vue-eslint-parser": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-8.0.1.tgz", diff --git a/src/jelu-ui/package.json b/src/jelu-ui/package.json index 0d917470..d30fd514 100644 --- a/src/jelu-ui/package.json +++ b/src/jelu-ui/package.json @@ -20,6 +20,7 @@ "@vueuse/core": "8.2.6", "@vueuse/router": "8.2.6", "axios": "0.26.1", + "chart.js": "^3.7.1", "daisyui": "2.14.3", "dayjs": "1.11.0", "floating-vue": "^2.0.0-beta.15", @@ -27,6 +28,7 @@ "theme-change": "^2.0.2", "vue": "3.2.33", "vue-avatar-sdh": "^1.0.3", + "vue-chartjs": "^4.1.0", "vue-i18n": "^9.1.9", "vue-router": "4.0.14", "vuejs-sidebar-menu": "^1.0.0", diff --git a/src/jelu-ui/src/components/AdminBase.vue b/src/jelu-ui/src/components/AdminBase.vue index c64127b8..3078b00e 100644 --- a/src/jelu-ui/src/components/AdminBase.vue +++ b/src/jelu-ui/src/components/AdminBase.vue @@ -19,6 +19,7 @@ const items = ref([{ name:t('settings.profile'), tooltip:t('settings.my_profile' { name:t('settings.authors'), icon:"bxs-user-account", href:"/profile/admin/authors", tooltip: t('settings.author_management') }, { name:t('settings.imports'), icon:"bxs-file-plus", href:"/profile/imports", tooltip: t('settings.csv_import') }, { name:t('settings.messages'), icon:"bxs-message-alt-detail", href:"/profile/messages" }, + { name:t('settings.stats'), icon:"bxs-chart", href:"/profile/stats", tooltip: t('settings.stats') }, ]) if (store.getters.isAdmin) { diff --git a/src/jelu-ui/src/components/UserStats.vue b/src/jelu-ui/src/components/UserStats.vue new file mode 100644 index 00000000..b1e76925 --- /dev/null +++ b/src/jelu-ui/src/components/UserStats.vue @@ -0,0 +1,152 @@ + + + + + diff --git a/src/jelu-ui/src/locales/en.json b/src/jelu-ui/src/locales/en.json index 85ae3e6d..673c153a 100644 --- a/src/jelu-ui/src/locales/en.json +++ b/src/jelu-ui/src/locales/en.json @@ -102,7 +102,8 @@ "users_management" : "Users management", "shortcuts" : "Shortcuts", "shortcuts_tooltip" : "Keyboard shortcuts", - "messages" : "Messages" + "messages" : "Messages", + "stats" : "Stats" }, "user" : { "log_first" : "Please log in first" @@ -244,5 +245,10 @@ "user-messages" : { "link" : "link", "mark_read": "mark as read" + }, + "stats" : { + "all_time" : "All time stats", + "yearly_stats" : "Yearly stats", + "choose_year" : "Choose a year" } } diff --git a/src/jelu-ui/src/locales/fr.json b/src/jelu-ui/src/locales/fr.json index e191f892..ac2860e5 100644 --- a/src/jelu-ui/src/locales/fr.json +++ b/src/jelu-ui/src/locales/fr.json @@ -103,7 +103,8 @@ "users_management" : "gestion des utilisateurs", "shortcuts" : "Raccourcis", "shortcuts_tooltip" : "Raccourcis claviers", - "messages" : "Messages" + "messages" : "Messages", + "stats" : "Statistiques" }, "user" : { "log_first" : "Veuillez vous identifier" @@ -245,5 +246,10 @@ "user-messages" : { "link" : "lien", "mark_read": "marquer comme lu" + }, + "stats" : { + "all_time" : "Données globales", + "yearly_stats" : "Statistiques annuelles", + "choose_year" : "Choisir une année" } } diff --git a/src/jelu-ui/src/model/YearStats.ts b/src/jelu-ui/src/model/YearStats.ts new file mode 100644 index 00000000..ddde860d --- /dev/null +++ b/src/jelu-ui/src/model/YearStats.ts @@ -0,0 +1,12 @@ +export interface YearStats { + dropped: number, + finished: number, + year: number +} + +export interface MonthStats { + dropped: number, + finished: number, + year: number, + month: number +} diff --git a/src/jelu-ui/src/router.ts b/src/jelu-ui/src/router.ts index 6b46444f..0b6e7a47 100644 --- a/src/jelu-ui/src/router.ts +++ b/src/jelu-ui/src/router.ts @@ -91,6 +91,7 @@ const router = createRouter({ { path: 'imports', component: () => import(/* webpackChunkName: "recommend" */ './components/Imports.vue')}, { path: 'settings', component: () => import(/* webpackChunkName: "recommend" */ './components/UserSettings.vue')}, { path: 'messages', component: () => import(/* webpackChunkName: "recommend" */ './components/UserMessages.vue')}, + { path: 'stats', component: () => import(/* webpackChunkName: "recommend" */ './components/UserStats.vue')}, ] }, ], diff --git a/src/jelu-ui/src/services/DataService.ts b/src/jelu-ui/src/services/DataService.ts index c6ccc444..e3629b88 100644 --- a/src/jelu-ui/src/services/DataService.ts +++ b/src/jelu-ui/src/services/DataService.ts @@ -16,6 +16,7 @@ import { LibraryFilter } from "../model/LibraryFilter"; import { WikipediaSearchResult } from "../model/WikipediaSearchResult"; import { WikipediaPageResult } from "../model/WikipediaPageResult"; import { MessageCategory, UpdateUserMessage, UserMessage } from "../model/UserMessage"; +import { MonthStats, YearStats } from "../model/YearStats"; class DataService { @@ -59,6 +60,8 @@ class DataService { private API_USER_MESSAGES = '/user-messages'; + private API_STATS = '/stats'; + private MODE: string; private BASE_URL: string; @@ -978,6 +981,54 @@ class DataService { } } + yearStats = async () => { + try { + const response = await this.apiClient.get>(`${this.API_STATS}`); + console.log("called stats") + console.log(response) + return response.data; + } + catch (error) { + if (axios.isAxiosError(error) && error.response) { + console.log("error axios " + error.response.status + " " + error.response.data.error) + } + console.log("error stats " + (error as AxiosError).code) + throw new Error("error stats " + error) + } + } + + monthStatsForYear = async (year: number) => { + try { + const response = await this.apiClient.get>(`${this.API_STATS}/${year}`); + console.log("called stats months") + console.log(response) + return response.data; + } + catch (error) { + if (axios.isAxiosError(error) && error.response) { + console.log("error axios " + error.response.status + " " + error.response.data.error) + } + console.log("error stats months " + (error as AxiosError).code) + throw new Error("error stats months " + error) + } + } + + yearsWithStats = async () => { + try { + const response = await this.apiClient.get>(`${this.API_STATS}/years`); + console.log("called stats years") + console.log(response) + return response.data; + } + catch (error) { + if (axios.isAxiosError(error) && error.response) { + console.log("error axios " + error.response.status + " " + error.response.data.error) + } + console.log("error stats years " + (error as AxiosError).code) + throw new Error("error stats years " + error) + } + } + } diff --git a/src/main/kotlin/io/github/bayang/jelu/controllers/ReadingEventsController.kt b/src/main/kotlin/io/github/bayang/jelu/controllers/ReadingEventsController.kt index cfd8f8d7..19acbd3e 100644 --- a/src/main/kotlin/io/github/bayang/jelu/controllers/ReadingEventsController.kt +++ b/src/main/kotlin/io/github/bayang/jelu/controllers/ReadingEventsController.kt @@ -4,14 +4,17 @@ import io.github.bayang.jelu.config.JeluProperties import io.github.bayang.jelu.dao.ReadingEventType import io.github.bayang.jelu.dto.CreateReadingEventDto import io.github.bayang.jelu.dto.JeluUser +import io.github.bayang.jelu.dto.MonthStatsDto import io.github.bayang.jelu.dto.ReadingEventDto import io.github.bayang.jelu.dto.UpdateReadingEventDto +import io.github.bayang.jelu.dto.YearStatsDto import io.github.bayang.jelu.dto.assertIsJeluUser import io.github.bayang.jelu.service.ReadingEventService import io.swagger.v3.oas.annotations.responses.ApiResponse import mu.KotlinLogging import org.springdoc.api.annotations.ParameterObject import org.springframework.data.domain.Page +import org.springframework.data.domain.PageRequest import org.springframework.data.domain.Pageable import org.springframework.data.domain.Sort import org.springframework.data.web.PageableDefault @@ -26,6 +29,8 @@ import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController +import java.time.OffsetDateTime +import java.time.ZoneId import java.util.UUID import javax.validation.Valid @@ -74,4 +79,77 @@ class ReadingEventsController( repository.deleteReadingEventById(eventId) return ResponseEntity.noContent().build() } + + @GetMapping(path = ["/stats"]) + fun stats( + principal: Authentication + ): ResponseEntity> { + var events: Page + var currentPage = 0 + val pageSize = 200 + val yearStats = mutableMapOf() + do { + events = repository.findAll(listOf(ReadingEventType.FINISHED, ReadingEventType.DROPPED), (principal.principal as JeluUser).user.id.value, null, PageRequest.of(currentPage, pageSize)) + currentPage ++ + events.forEach { + val year = OffsetDateTime.ofInstant(it.modificationDate, ZoneId.systemDefault()).year + if (yearStats.containsKey(year)) { + if (it.eventType == ReadingEventType.DROPPED) { + yearStats[year] = yearStats[year]!!.copy(dropped = yearStats[year]!!.dropped + 1) + } else if (it.eventType == ReadingEventType.FINISHED) { + yearStats[year] = yearStats[year]!!.copy(finished = yearStats[year]!!.finished + 1) + } + } else { + if (it.eventType == ReadingEventType.DROPPED) { + yearStats[year] = YearStatsDto(year = year, dropped = 1) + } else if (it.eventType == ReadingEventType.FINISHED) { + yearStats[year] = YearStatsDto(year = year, finished = 1) + } + } + } + } while (!events.isEmpty) + return ResponseEntity.ok(yearStats.values.toList()) + } + + @GetMapping(path = ["/stats/{year}"]) + fun statsForYear( + @PathVariable("year") year: Int, + principal: Authentication + ): ResponseEntity> { + var events: Page + var currentPage = 0 + val pageSize = 200 + val monthStats = mutableMapOf() + do { + events = repository.findAll(listOf(ReadingEventType.FINISHED, ReadingEventType.DROPPED), (principal.principal as JeluUser).user.id.value, null, PageRequest.of(currentPage, pageSize)) + currentPage ++ + events.filter { OffsetDateTime.ofInstant(it.modificationDate, ZoneId.systemDefault()).year == year }.forEach { + val toDate = OffsetDateTime.ofInstant(it.modificationDate, ZoneId.systemDefault()) + val month = toDate.monthValue + if (monthStats.containsKey(month)) { + if (it.eventType == ReadingEventType.DROPPED) { + monthStats[month] = monthStats[month]!!.copy(dropped = monthStats[month]!!.dropped + 1) + } else if (it.eventType == ReadingEventType.FINISHED) { + monthStats[month] = monthStats[month]!!.copy(finished = monthStats[month]!!.finished + 1) + } + } else { + if (it.eventType == ReadingEventType.DROPPED) { + monthStats[month] = MonthStatsDto(year = year, dropped = 1, month = month) + } else if (it.eventType == ReadingEventType.FINISHED) { + monthStats[month] = MonthStatsDto(year = year, finished = 1, month = month) + } + } + } + } while (!events.isEmpty) + return ResponseEntity.ok(monthStats.values.toList()) + } + + @ApiResponse(description = "Return a list of years for which there are reading events") + @GetMapping(path = ["/stats/years"]) + fun years( + principal: Authentication + ): ResponseEntity> { + val years = repository.findYears(listOf(ReadingEventType.FINISHED, ReadingEventType.DROPPED), (principal.principal as JeluUser).user.id.value, null) + return ResponseEntity.ok(years) + } } diff --git a/src/main/kotlin/io/github/bayang/jelu/dao/ReadingEventRepository.kt b/src/main/kotlin/io/github/bayang/jelu/dao/ReadingEventRepository.kt index 4cbe945f..749a016f 100644 --- a/src/main/kotlin/io/github/bayang/jelu/dao/ReadingEventRepository.kt +++ b/src/main/kotlin/io/github/bayang/jelu/dao/ReadingEventRepository.kt @@ -10,6 +10,7 @@ import org.jetbrains.exposed.sql.JoinType import org.jetbrains.exposed.sql.SortOrder import org.jetbrains.exposed.sql.and import org.jetbrains.exposed.sql.andWhere +import org.jetbrains.exposed.sql.javatime.year import org.jetbrains.exposed.sql.selectAll import org.springframework.data.domain.Page import org.springframework.data.domain.PageImpl @@ -51,6 +52,27 @@ class ReadingEventRepository { ) } + fun findYears( + eventTypes: List?, + userId: UUID?, + bookId: UUID?, + ): List { + val query = ReadingEventTable.join(UserBookTable, JoinType.LEFT) + .slice(ReadingEventTable.modificationDate.year()) + .selectAll() + if (eventTypes != null && eventTypes.isNotEmpty()) { + query.andWhere { ReadingEventTable.eventType inList eventTypes } + } + if (userId != null) { + query.andWhere { UserBookTable.user eq userId } + } + if (bookId != null) { + query.andWhere { UserBookTable.book eq bookId } + } + query.withDistinct(true) + return query.map { resultRow -> resultRow[ReadingEventTable.modificationDate.year()] }.toList() + } + fun save(createReadingEventDto: CreateReadingEventDto, targetUser: User): ReadingEvent { if (createReadingEventDto.bookId == null) { throw JeluException("Missing bookId to create reading event") diff --git a/src/main/kotlin/io/github/bayang/jelu/dto/ReadStatsDto.kt b/src/main/kotlin/io/github/bayang/jelu/dto/ReadStatsDto.kt new file mode 100644 index 00000000..a3f681f5 --- /dev/null +++ b/src/main/kotlin/io/github/bayang/jelu/dto/ReadStatsDto.kt @@ -0,0 +1,14 @@ +package io.github.bayang.jelu.dto + +data class YearStatsDto( + val dropped: Int = 0, + val finished: Int = 0, + val year: Int +) + +data class MonthStatsDto( + val dropped: Int = 0, + val finished: Int = 0, + val year: Int, + val month: Int, +) diff --git a/src/main/kotlin/io/github/bayang/jelu/service/ReadingEventService.kt b/src/main/kotlin/io/github/bayang/jelu/service/ReadingEventService.kt index 5b06db0e..e03e24b1 100644 --- a/src/main/kotlin/io/github/bayang/jelu/service/ReadingEventService.kt +++ b/src/main/kotlin/io/github/bayang/jelu/service/ReadingEventService.kt @@ -18,6 +18,10 @@ class ReadingEventService(private val readingEventRepository: ReadingEventReposi fun findAll(eventTypes: List?, userId: UUID?, bookId: UUID?, pageable: Pageable) = readingEventRepository.findAll(eventTypes, userId, bookId, pageable).map { it.toReadingEventDto() } + @Transactional + fun findYears(eventTypes: List?, userId: UUID?, bookId: UUID?) = + readingEventRepository.findYears(eventTypes, userId, bookId) + @Transactional fun save(createReadingEventDto: CreateReadingEventDto, user: User): ReadingEventDto { return readingEventRepository.save(createReadingEventDto, user).toReadingEventDto()