Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ dependencies {

// Hypersistence Utils
implementation "io.hypersistence:hypersistence-utils-hibernate-63:3.7.6"

// Thymeleaf (통계 페이지용)
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
}

dependencyManagement {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package com.devkor.ifive.nadab.domain.stats.application;

import com.devkor.ifive.nadab.domain.stats.core.dto.daily.DailyStatsViewModel;
import com.devkor.ifive.nadab.domain.stats.core.dto.daily.DateCountDto;
import com.devkor.ifive.nadab.domain.stats.core.repository.DailyStatsRepository;
import com.devkor.ifive.nadab.global.shared.util.TodayDateTimeProvider;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.time.LocalDate;

@Service
@RequiredArgsConstructor
public class DailyStatsService {

private final DailyStatsRepository repo;

private static final ZoneId SEOUL = ZoneId.of("Asia/Seoul");
private static final DateTimeFormatter FMT =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

public DailyStatsViewModel getDailyStatsLast7Days() {
LocalDate today = TodayDateTimeProvider.getTodayDate();
LocalDate startDate = today.minusDays(6);

// 라벨 7개 고정 생성
List<LocalDate> days = new ArrayList<>();
for (int i = 0; i < 7; i++) days.add(startDate.plusDays(i));

List<String> labels = days.stream().map(LocalDate::toString).toList();

// 1) 가입자 수
Map<LocalDate, Long> signupMap = new HashMap<>();
for (Object[] row : repo.findSignupCountsLast7Days(startDate, today)) {
DateCountDto dto = DailyStatsRepository.toDateCountDto(row);
signupMap.put(dto.date(), dto.count());
}

// 2) 할당 질문 수
Map<LocalDate, Long> assignedMap = new HashMap<>();
for (DateCountDto dto : repo.findAssignedQuestionCountsLast7Days(startDate, today)) {
assignedMap.put(dto.date(), dto.count());
}

// 3) COMPLETED 리포트 수
Map<LocalDate, Long> completedMap = new HashMap<>();
for (DateCountDto dto : repo.findCompletedDailyReportCountsLast7Days(startDate, today)) {
completedMap.put(dto.date(), dto.count());
}

// 빈 날짜는 0 채우기
List<Long> signupCounts = days.stream().map(d -> signupMap.getOrDefault(d, 0L)).toList();
List<Long> assignedCounts = days.stream().map(d -> assignedMap.getOrDefault(d, 0L)).toList();
List<Long> completedCounts = days.stream().map(d -> completedMap.getOrDefault(d, 0L)).toList();

long sharedNow = repo.countSharedDailyReportsNow();

return new DailyStatsViewModel(
labels,
signupCounts,
assignedCounts,
completedCounts,
sharedNow,
OffsetDateTime.now(SEOUL).format(FMT)
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package com.devkor.ifive.nadab.domain.stats.application;

import com.devkor.ifive.nadab.domain.stats.core.dto.total.LabelCountDto;
import com.devkor.ifive.nadab.domain.stats.core.dto.total.TotalStatsViewModel;
import com.devkor.ifive.nadab.domain.stats.core.repository.TotalStatsRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.*;

@Service
@RequiredArgsConstructor
public class TotalStatsService {

private final TotalStatsRepository repo;

private static final ZoneId SEOUL = ZoneId.of("Asia/Seoul");
private static final DateTimeFormatter FMT =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

public TotalStatsViewModel getTotalStats() {
long totalUserCount = repo.countTotalUsers();

// provider
Map<String, Long> providerMap = new HashMap<>();
for (Object[] row : repo.countUsersByProvider()) {
String provider = String.valueOf(row[0]); // GOOGLE/KAKAO/NAVER
long cnt = (Long) row[1];
providerMap.put(provider, cnt);
}
long normal = repo.countNormalUsers();
providerMap.put("NORMAL", normal);

// 표시 순서 고정
List<String> providerLabels = List.of("GOOGLE", "KAKAO", "NAVER", "NORMAL");
List<Long> providerCounts = providerLabels.stream()
.map(l -> providerMap.getOrDefault(l, 0L))
.toList();

// interest pie
List<LabelCountDto> selected = repo.countInterestSelections();
List<String> interestLabels = selected.stream().map(LabelCountDto::label).toList();
List<Long> interestSelectedCounts = selected.stream().map(LabelCountDto::count).toList();

// interest bar (completed daily reports)
Map<String, Long> reportMap = new HashMap<>();
for (LabelCountDto dto : repo.countCompletedDailyReportsByInterest()) {
reportMap.put(dto.label(), dto.count());
}
List<Long> interestDailyReportCounts = interestLabels.stream()
.map(l -> reportMap.getOrDefault(l, 0L))
.toList();

String refreshedAt = OffsetDateTime.now(SEOUL).format(FMT);

return new TotalStatsViewModel(
totalUserCount,
providerLabels,
providerCounts,
interestLabels,
interestSelectedCounts,
interestDailyReportCounts,
refreshedAt
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.devkor.ifive.nadab.domain.stats.controller;

import com.devkor.ifive.nadab.domain.stats.application.DailyStatsService;
import com.devkor.ifive.nadab.domain.stats.application.TotalStatsService;
import com.devkor.ifive.nadab.domain.stats.core.dto.daily.DailyStatsViewModel;
import com.devkor.ifive.nadab.domain.stats.core.dto.total.TotalStatsViewModel;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
@RequiredArgsConstructor
public class StatsController {

private final DailyStatsService dailyStatsService;
private final TotalStatsService totalStatsService;


@GetMapping("stats/daily")
public String dailyStats(Model model) {
DailyStatsViewModel vm = dailyStatsService.getDailyStatsLast7Days();
model.addAttribute("vm", vm);
model.addAttribute("activeTab", "daily");
return "stats/daily";
}

@GetMapping("/stats/total")
public String totalStats(Model model) {
TotalStatsViewModel vm = totalStatsService.getTotalStats();
model.addAttribute("vm", vm);
model.addAttribute("activeTab", "total");
return "stats/total";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.devkor.ifive.nadab.domain.stats.core.dto.daily;

import java.util.List;

public record DailyStatsViewModel(
List<String> labels, // yyyy-MM-dd
List<Long> signupCounts,
List<Long> assignedQuestionCounts,
List<Long> completedDailyReportCounts,
long sharedDailyReportCount,
String refreshedAt // "2026-02-27 21:34:12"
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.devkor.ifive.nadab.domain.stats.core.dto.daily;

import java.time.LocalDate;

public record DateCountDto(
LocalDate date,
long count
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.devkor.ifive.nadab.domain.stats.core.dto.total;

public record LabelCountDto(
String label,
long count
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.devkor.ifive.nadab.domain.stats.core.dto.total;

import java.util.List;

public record TotalStatsViewModel(

long totalUserCount,

// provider labels: ["GOOGLE","KAKAO","NAVER","NORMAL"]
List<String> providerLabels,
List<Long> providerCounts,

// interest labels: ["PREFERENCE", ...]
List<String> interestLabels,
List<Long> interestSelectedCounts, // pie
List<Long> interestDailyReportCounts, // bar (COMPLETED)

String refreshedAt
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package com.devkor.ifive.nadab.domain.stats.core.repository;

import com.devkor.ifive.nadab.domain.stats.core.dto.daily.DateCountDto;
import com.devkor.ifive.nadab.global.shared.util.TodayDateTimeProvider;
import jakarta.persistence.EntityManager;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

import java.sql.Date;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.util.List;

@Repository
@RequiredArgsConstructor
public class DailyStatsRepository {

private static final ZoneId SEOUL = ZoneId.of("Asia/Seoul");
private final EntityManager em;

/**
* 최근 7일 가입자 수 (registeredAt 기준)
*/
public List<Object[]> findSignupCountsLast7Days(LocalDate startDate, LocalDate endDateInclusive) {
OffsetDateTime start = startDate.atStartOfDay(SEOUL).toOffsetDateTime();
OffsetDateTime endExclusive = endDateInclusive.plusDays(1).atStartOfDay(SEOUL).toOffsetDateTime();

// JPQL에서 "날짜 단위"로 묶기 위해 Postgres date() 함수 사용
// function('date', u.registeredAt) -> java.sql.Date 로 내려오는 경우가 많아서 Object[]로 받는게 안전
return em.createQuery("""
select function('date', u.registeredAt), count(u.id)
from User u
where u.deletedAt is null
and u.signupStatus = com.devkor.ifive.nadab.domain.user.core.entity.SignupStatusType.COMPLETED
and u.registeredAt is not null
and u.registeredAt >= :start
and u.registeredAt < :endExclusive
group by function('date', u.registeredAt)
order by function('date', u.registeredAt)
""", Object[].class)
.setParameter("start", start)
.setParameter("endExclusive", endExclusive)
.getResultList();
}

/**
* 최근 7일 할당된 질문 수 (user_daily_questions)
*/
public List<DateCountDto> findAssignedQuestionCountsLast7Days(LocalDate startDate, LocalDate endDateInclusive) {
return em.createQuery("""
select new com.devkor.ifive.nadab.domain.stats.core.dto.daily.DateCountDto(udq.date, count(udq.id))
from UserDailyQuestion udq
where udq.date between :startDate and :endDate
group by udq.date
order by udq.date
""", DateCountDto.class)
.setParameter("startDate", startDate)
.setParameter("endDate", endDateInclusive)
.getResultList();
}

/**
* 최근 7일 COMPLETED daily_reports 수
*/
public List<DateCountDto> findCompletedDailyReportCountsLast7Days(LocalDate startDate, LocalDate endDateInclusive) {
return em.createQuery("""
select new com.devkor.ifive.nadab.domain.stats.core.dto.daily.DateCountDto(dr.date, count(dr.id))
from DailyReport dr
where dr.date between :startDate and :endDate
and dr.status = 'COMPLETED'
group by dr.date
order by dr.date
""", DateCountDto.class)
.setParameter("startDate", startDate)
.setParameter("endDate", endDateInclusive)
.getResultList();
}

/**
* 현재 공유 중(isShared=true) daily_reports 개수
* - 보통 COMPLETED만 공유 의미가 있으니 status도 거는 걸 추천
*/
public long countSharedDailyReportsNow() {
LocalDate today = TodayDateTimeProvider.getTodayDate();

return em.createQuery("""
select count(dr.id)
from DailyReport dr
where dr.date = :today
and dr.isShared = true
and dr.status = 'COMPLETED'
""", Long.class)
.setParameter("today", today)
.getSingleResult();
}

// ---------- 유틸: Object[] -> DateCountDto 변환에 사용할 때 ----------
public static DateCountDto toDateCountDto(Object[] row) {
// row[0] = java.sql.Date (대개), row[1] = Long
LocalDate date = ((Date) row[0]).toLocalDate();
long count = (Long) row[1];
return new DateCountDto(date, count);
}
}
Loading