added previous stats data to api responses - per each stats data provided

develop
bvn13 2020-04-21 02:21:13 +03:00
parent c70993ebff
commit 3006a00ebb
13 changed files with 331 additions and 86 deletions

View File

@ -24,7 +24,7 @@ import java.time.LocalDateTime;
@Data
@Entity
@Table(schema = "covid", name = "cvd_stats")
public class CovidStat {
public class CovidStat implements StatsProvider {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY, generator = "cvd_stats_seq")

View File

@ -0,0 +1,23 @@
/*
Copyright [2020] [bvn13]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package com.bvn13.covid19.model.entities;
public interface StatsProvider {
long getSick();
long getHealed();
long getDied();
}

View File

@ -1,11 +1,30 @@
/*
Copyright [2020] [bvn13]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package com.bvn13.covid19.site.controllers;
import com.bvn13.covid19.model.entities.CovidStat;
import com.bvn13.covid19.model.entities.CovidUpdate;
import com.bvn13.covid19.model.entities.Region;
import com.bvn13.covid19.model.entities.StatsProvider;
import com.bvn13.covid19.site.model.CovidAllStats;
import com.bvn13.covid19.site.model.CovidData;
import com.bvn13.covid19.site.model.CovidDayStats;
import com.bvn13.covid19.site.service.CovidStatsMaker;
import com.bvn13.covid19.site.service.CovidStatsResponseMaker;
import com.bvn13.covid19.site.repositories.RegionsRepository;
import com.bvn13.covid19.site.service.StatisticsPreparator;
import com.bvn13.covid19.site.service.StatisticsAggregator;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value;
@ -16,10 +35,7 @@ import org.springframework.web.bind.annotation.RestController;
import javax.annotation.PostConstruct;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.*;
import java.util.stream.Collectors;
@RequiredArgsConstructor
@ -27,8 +43,9 @@ import java.util.stream.Collectors;
@RequestMapping("/stats")
public class AllStatsController {
private final CovidStatsMaker covidStatsMaker;
private final CovidStatsResponseMaker covidStatsResponseMaker;
private final StatisticsPreparator statisticsPreparator;
private final StatisticsAggregator statisticsAggregator;
private final RegionsRepository regionsRepository;
@Value("${app.zone-id}")
private String zoneIdStr;
@ -51,45 +68,53 @@ public class AllStatsController {
}
private CovidAllStats constructResponseForRegion(String regionName) {
return CovidAllStats.builder()
.regions(Collections.singletonList(regionName))
.progress(covidStatsMaker.findAllLastUpdatesPerDay().stream()
.map(covidUpdate -> CovidDayStats.builder()
.datetime(covidUpdate.getDatetime())
.updatedOn(covidUpdate.getCreatedOn().atZone(zoneId))
.stats(convertStatsToData(findCovidStatsByUpdateIdAndRegion(covidUpdate.getId(), regionName)))
.build())
.sorted(CovidDayStats::compareTo)
.collect(Collectors.toList()))
.build();
return regionsRepository.findFirstByName(regionName)
.map(region -> CovidAllStats.builder()
.regions(Collections.singletonList(region.getName()))
.progress(compoundProgressForRegions(Collections.singletonList(region)))
.build()
)
.orElse(CovidAllStats.builder()
.regions(Collections.singletonList(regionName))
.progress(Collections.emptyList())
.build()
);
}
private CovidAllStats constructResponseForAllRegions() {
return CovidAllStats.builder()
.regions(covidStatsMaker.findAllRegionsNames())
.progress(covidStatsMaker.findAllLastUpdatesPerDay().stream()
.map(covidUpdate -> CovidDayStats.builder()
.datetime(covidUpdate.getDatetime())
.updatedOn(covidUpdate.getCreatedOn().atZone(zoneId))
.stats(convertStatsToData(findCovidStatsByUpdateId(covidUpdate.getId())))
.build())
.sorted(CovidDayStats::compareTo)
.collect(Collectors.toList()))
.regions(statisticsPreparator.findAllRegionsNames())
.progress(compoundProgressForRegions(regionsRepository.findAll()))
.build();
}
private List<CovidData> convertStatsToData(Collection<CovidStat> stats) {
return covidStatsResponseMaker.convertStats(stats);
}
private List<CovidStat> findCovidStatsByUpdateIdAndRegion(long updateId, String region) {
return covidStatsMaker.findCovidStatsByUpdateInfoId(updateId).stream()
.filter(covidStat -> region.equals(covidStat.getRegion().getName()))
private List<CovidDayStats> compoundProgressForRegions(List<Region> regions) {
return statisticsPreparator.findAllLastUpdatesPerDay().stream()
.map(covidUpdate -> prepareDayStats(covidUpdate, regions))
.sorted(CovidDayStats::compareTo)
.collect(Collectors.toList());
}
private List<CovidStat> findCovidStatsByUpdateId(long updateId) {
return new ArrayList<>(covidStatsMaker.findCovidStatsByUpdateInfoId(updateId));
private CovidDayStats prepareDayStats(CovidUpdate currentUpdate, List<Region> regions) {
List<CovidStat> currentStats = findCovidStatsByUpdateIdAndRegion(currentUpdate.getId(), regions);
Optional<CovidUpdate> prevUpdate = statisticsPreparator.findPrevUpdateByDate(currentUpdate.getDatetime());
Map<Region, StatsProvider> prevStats = prevUpdate
.map(covidUpdate -> statisticsPreparator.findCovidStatsByUpdateInfoId(covidUpdate.getId()).stream()
.collect(Collectors.toMap(CovidStat::getRegion, (cs) -> (StatsProvider) cs))
)
.orElseGet(() -> new HashMap<>(0));
return CovidDayStats.builder()
.datetime(currentUpdate.getDatetime())
.updatedOn(currentUpdate.getCreatedOn().atZone(zoneId))
.stats(statisticsAggregator.prepareStats(currentStats, prevStats))
.build();
}
private List<CovidStat> findCovidStatsByUpdateIdAndRegion(long updateId, List<Region> regions) {
return statisticsPreparator.findCovidStatsByUpdateInfoId(updateId).stream()
.filter(covidStat -> regions.contains(covidStat.getRegion()))
.collect(Collectors.toList());
}
}

View File

@ -16,9 +16,13 @@ limitations under the License.
package com.bvn13.covid19.site.controllers;
import com.bvn13.covid19.model.entities.CovidStat;
import com.bvn13.covid19.model.entities.CovidUpdate;
import com.bvn13.covid19.model.entities.Region;
import com.bvn13.covid19.model.entities.StatsProvider;
import com.bvn13.covid19.site.model.CovidDayStats;
import com.bvn13.covid19.site.service.CovidStatsMaker;
import com.bvn13.covid19.site.service.CovidStatsResponseMaker;
import com.bvn13.covid19.site.service.StatisticsPreparator;
import com.bvn13.covid19.site.service.StatisticsAggregator;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
@ -27,14 +31,19 @@ import org.springframework.web.bind.annotation.RestController;
import javax.annotation.PostConstruct;
import java.time.ZoneId;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
@RequiredArgsConstructor
@RestController
@RequestMapping("/stats")
public class LastStatsController {
private final CovidStatsMaker covidStatsMaker;
private final CovidStatsResponseMaker covidStatsResponseMaker;
private final StatisticsPreparator statisticsPreparator;
private final StatisticsAggregator statisticsAggregator;
@Value("${app.zone-id}")
private String zoneIdStr;
@ -47,13 +56,24 @@ public class LastStatsController {
@GetMapping("/last")
public CovidDayStats getStatistics() {
return covidStatsMaker.findLastUpdate()
.map(covidUpdate -> CovidDayStats.builder()
.datetime(covidUpdate.getDatetime())
.updatedOn(covidUpdate.getCreatedOn().atZone(zoneId))
.stats(covidStatsResponseMaker.convertStats(covidStatsMaker.findCovidStatsByUpdateInfoId(covidUpdate.getId())))
.build())
.orElse(CovidDayStats.builder().build());
Optional<CovidUpdate> lastUpdate = statisticsPreparator.findLastUpdate();
if (lastUpdate.isPresent()) {
Collection<CovidStat> currentStats = statisticsPreparator.findCovidStatsByUpdateInfoId(lastUpdate.get().getId());
Optional<CovidUpdate> prevUpdate = statisticsPreparator.findPrevUpdateByDate(lastUpdate.get().getDatetime());
Map<Region, StatsProvider> prevStats = prevUpdate
.map(covidUpdate -> statisticsPreparator.findCovidStatsByUpdateInfoId(covidUpdate.getId()).stream()
.collect(Collectors.toMap(CovidStat::getRegion, (cs) -> (StatsProvider) cs))
)
.orElseGet(() -> new HashMap<>(0));
return CovidDayStats.builder()
.datetime(lastUpdate.get().getDatetime())
.updatedOn(lastUpdate.get().getCreatedOn().atZone(zoneId))
.stats(statisticsAggregator.prepareStats(currentStats, prevStats))
.build();
} else {
return CovidDayStats.builder().build();
}
}
}

View File

@ -1,6 +1,22 @@
/*
Copyright [2020] [bvn13]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package com.bvn13.covid19.site.controllers;
import com.bvn13.covid19.site.service.CovidStatsMaker;
import com.bvn13.covid19.site.service.StatisticsPreparator;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@ -13,11 +29,11 @@ import java.util.List;
@RequestMapping("/regions")
public class RegionsController {
private final CovidStatsMaker covidStatsMaker;
private final StatisticsPreparator statisticsPreparator;
@GetMapping
public List<String> getAllRegions() {
return covidStatsMaker.findAllRegionsNames();
return statisticsPreparator.findAllRegionsNames();
}
}

View File

@ -16,16 +16,27 @@ limitations under the License.
package com.bvn13.covid19.site.model;
import com.bvn13.covid19.model.entities.StatsProvider;
import lombok.Builder;
import lombok.Value;
@Builder
@Builder(toBuilder = true)
@Value
public class CovidData {
public class CovidData implements StatsProvider {
String region;
long sick;
long healed;
long died;
Delta previous;
@Builder
@Value
public static class Delta implements StatsProvider {
long sick;
long healed;
long died;
}
}

View File

@ -20,9 +20,12 @@ import com.bvn13.covid19.site.dtos.CovidUpdateInfoDto;
import com.bvn13.covid19.model.entities.CovidUpdate;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZonedDateTime;
import java.util.Collection;
import java.util.Optional;
@ -32,6 +35,9 @@ public interface CovidUpdatesRepository extends JpaRepository<CovidUpdate, Long>
@Query("select new com.bvn13.covid19.site.dtos.CovidUpdateInfoDto(max(U.createdOn)) from CovidUpdate U")
Optional<CovidUpdateInfoDto> findLastUpdate();
@Query("select U from CovidUpdate U where U.datetime >= :date1 and U.datetime < :date2")
Optional<CovidUpdate> findByDateOfUpdate(@Param("date1") ZonedDateTime date1, @Param("date2") ZonedDateTime date2);
@Query("select U from CovidUpdate U where U.id in (select max(U1.id) from CovidUpdate U1 group by U1.datetime)")
Collection<CovidUpdate> findAllLastUpdatesPerDay();

View File

@ -4,6 +4,9 @@ import com.bvn13.covid19.model.entities.Region;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface RegionsRepository extends JpaRepository<Region, Long> {
Optional<Region> findFirstByName(String name);
}

View File

@ -1,25 +0,0 @@
package com.bvn13.covid19.site.service;
import com.bvn13.covid19.model.entities.CovidStat;
import com.bvn13.covid19.site.model.CovidData;
import org.springframework.stereotype.Component;
import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;
@Component
public class CovidStatsResponseMaker {
public List<CovidData> convertStats(Collection<CovidStat> stats) {
return stats.stream()
.map(stat -> CovidData.builder()
.region(stat.getRegion().getName())
.sick(stat.getSick())
.healed(stat.getHealed())
.died(stat.getDied())
.build())
.collect(Collectors.toList());
}
}

View File

@ -0,0 +1,63 @@
/*
Copyright [2020] [bvn13]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package com.bvn13.covid19.site.service;
import com.bvn13.covid19.model.entities.CovidStat;
import com.bvn13.covid19.model.entities.Region;
import com.bvn13.covid19.model.entities.StatsProvider;
import com.bvn13.covid19.site.model.CovidData;
import lombok.RequiredArgsConstructor;
import org.springframework.data.util.Pair;
import org.springframework.stereotype.Component;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
@RequiredArgsConstructor
@Component
public class StatisticsAggregator {
private static final StatsProvider zero = CovidData.Delta.builder()
.sick(0L)
.healed(0L)
.died(0L)
.build();
public List<CovidData> prepareStats(Collection<CovidStat> current, Map<Region, StatsProvider> previous) {
return current.stream()
.map(stat -> Pair.of(stat, Optional.ofNullable(previous.get(stat.getRegion()))))
.map(statPair -> CovidData.builder()
.region(statPair.getFirst().getRegion().getName())
.sick(statPair.getFirst().getSick())
.healed(statPair.getFirst().getHealed())
.died(statPair.getFirst().getDied())
.previous(CovidData.Delta.builder()
.sick(statPair.getSecond().orElse(zero).getSick())
.healed(statPair.getSecond().orElse(zero).getHealed())
.died(statPair.getSecond().orElse(zero).getDied())
.build()
)
.build()
)
.collect(Collectors.toList());
}
}

View File

@ -23,10 +23,17 @@ import com.bvn13.covid19.site.repositories.CovidStatsRepository;
import com.bvn13.covid19.site.repositories.CovidUpdatesRepository;
import com.bvn13.covid19.site.repositories.RegionsRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import javax.transaction.Transactional;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.temporal.ChronoUnit;
import java.time.temporal.TemporalUnit;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
@ -35,12 +42,30 @@ import java.util.stream.Collectors;
@RequiredArgsConstructor
@Component
public class CovidStatsMaker {
public class StatisticsPreparator {
private final CovidStatsRepository statsRepository;
private final CovidUpdatesRepository updatesRepository;
private final RegionsRepository regionsRepository;
private LocalDate projectStartDate;
private ZonedDateTime projectStartZonedDate;
@Value("${app.zone-id}")
private String zoneIdStr;
@PostConstruct
public void init() {
ZoneId zoneId = ZoneId.of(zoneIdStr);
projectStartZonedDate = projectStartDate.atTime(0, 0, 0).atZone(zoneId);
}
@Value("${app.project-start-date}")
private void setProjectStartDate(String ld) {
if (ld != null && !ld.isEmpty()) {
projectStartDate = LocalDate.parse(ld);
}
}
@Transactional
public Collection<CovidStat> getLastCovidStats() {
return updatesRepository.findLastUpdate()
@ -59,6 +84,26 @@ public class CovidStatsMaker {
.flatMap(updateInfo -> updatesRepository.findFirstByCreatedOn(updateInfo.getCreatedOn()));
}
@Cacheable(
cacheNames = "covid-prev-update-by-date",
unless = "#result == null"
)
public Optional<CovidUpdate> findPrevUpdateByDate(ZonedDateTime date) {
if (date.isBefore(projectStartZonedDate)) {
return Optional.empty();
} else {
Optional<CovidUpdate> update = updatesRepository.findByDateOfUpdate(
dateWithTime(prevDate(date), 0, 0, 0),
dateWithTime(date, 0, 0, 0)
);
if (update.isPresent()) {
return update;
} else {
return findPrevUpdateByDate(prevDate(date));
}
}
}
@Cacheable(
cacheNames = "covid-stats-by-update-info-id",
condition = "#updateInfoId > 0",
@ -84,4 +129,15 @@ public class CovidStatsMaker {
return regionsRepository.findAll().stream().map(Region::getName).collect(Collectors.toList());
}
private ZonedDateTime prevDate(ZonedDateTime date) {
return date.minus(1, ChronoUnit.DAYS);
}
private ZonedDateTime dateWithTime(ZonedDateTime date, int hour, int minute, int second) {
return date
.withHour(hour)
.withMinute(minute)
.withSecond(second);
}
}

View File

@ -4,6 +4,7 @@ server:
app:
zone-id: Europe/Moscow
main-url: http://localhost:8080
project-start-date: 2020-03-01
spring:
application:
@ -13,7 +14,7 @@ spring:
type: caffeine
caffeine:
spec: expireAfterWrite=15m
cache-names: covid-last-update, covid-all-days-updates, covid-stats-by-update-info-id, covid-regions
cache-names: covid-last-update, covid-all-days-updates, covid-stats-by-update-info-id, covid-regions, covid-prev-update-by-date
flyway:
enabled: false

View File

@ -2,7 +2,7 @@
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>Home page</title>
<title>Статистика COVID19 в России</title>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
@ -28,6 +28,13 @@
<canvas id="chart"></canvas>
</div>
<p>
<a href="https://github.com/bvn13/covid19-ru">Исходный код</a>
</p>
<p>
<a href="https://twitter.com/bvn13">@bvn13 - Автор проекта</a>
</p>
<script th:inline="javascript">
var mainUrl = [[${@mainConfig.getMainUrl()}]];
@ -68,34 +75,73 @@
function showData(json) {
var labels = _.map(json.progress, (progress) => progress.datetime.substr(0, 10));
var sick = _.map(json.progress, (progress) => progress.stats.length > 0 ? progress.stats[0].sick : 0);
var sickDeltas = _.map(json.progress, (progress) =>
progress.stats.length > 0
? progress.stats[0].sick - progress.stats[0].previous.sick
: 0
);
var healed = _.map(json.progress, (progress) => progress.stats.length > 0 ? progress.stats[0].healed : 0);
var healedDeltas = _.map(json.progress, (progress) =>
progress.stats.length > 0
? progress.stats[0].healed - progress.stats[0].previous.healed
: 0
);
var died = _.map(json.progress, (progress) => progress.stats.length > 0 ? progress.stats[0].died : 0);
var diedDeltas = _.map(json.progress, (progress) =>
progress.stats.length > 0
? progress.stats[0].died - progress.stats[0].previous.died
: 0
);
var myChart = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: 'Всего',
label: 'Всего (чел.)',
data: sick,
backgroundColor: 'red',
borderColor: 'red',
borderWidth: 1,
fill: false
}, {
label: 'Выздоровело',
label: 'Выздоровело (чел.)',
data: healed,
backgroundColor: 'green',
borderColor: 'green',
borderWidth: 1,
fill: false
}, {
label: 'Умерло',
label: 'Умерло (чел.)',
data: died,
backgroundColor: 'black',
borderColor: 'black',
borderWidth: 1,
fill: false
}, {
label: 'Всего (дельта чел.)',
data: sickDeltas,
backgroundColor: 'red',
borderColor: 'red',
borderWidth: 1,
borderDash: [5, 15],
fill: false
}, {
label: 'Выздоровело (дельта чел.)',
data: healedDeltas,
backgroundColor: 'green',
borderColor: 'green',
borderWidth: 1,
borderDash: [5, 15],
fill: false
}, {
label: 'Умерло (дельта чел.)',
data: diedDeltas,
backgroundColor: 'black',
borderColor: 'black',
borderWidth: 1,
borderDash: [5, 15],
fill: false
}]
},
options: {