-
Notifications
You must be signed in to change notification settings - Fork 0
EX-262: Create widgets #81
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
fef8e64
9fd85ad
fc5dac1
7258cd7
7e261b3
632be1f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| package com.exence.finance.common.exception; | ||
|
|
||
| public class WidgetNotFoundException extends RuntimeException { | ||
| public WidgetNotFoundException() { | ||
| super(); | ||
| } | ||
|
|
||
| public WidgetNotFoundException(String context) { | ||
| super(context); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| package com.exence.finance.common.util; | ||
|
|
||
| import java.time.DayOfWeek; | ||
| import java.time.Instant; | ||
| import java.time.LocalDate; | ||
| import java.time.Month; | ||
| import java.time.YearMonth; | ||
| import java.time.ZoneId; | ||
| import java.time.ZoneOffset; | ||
| import java.time.format.TextStyle; | ||
| import java.time.temporal.ChronoUnit; | ||
| import java.time.temporal.IsoFields; | ||
| import java.util.ArrayList; | ||
| import java.util.List; | ||
| import java.util.Locale; | ||
| import lombok.experimental.UtilityClass; | ||
|
|
||
| // TODO: timezone AND Locale settings should be calculated from the user's profile settings | ||
| @UtilityClass | ||
| public final class DateUtils { | ||
|
|
||
| public static final ZoneId DISPLAY_ZONE = ZoneId.of("Europe/Budapest"); | ||
| public static final int DAYS_PER_WEEK = 7; | ||
|
|
||
| public static final int DECEMBER = 12; | ||
| public static final int LAST_WEEK_DECEMBER_DAY = 28; // dec 28 is always in the last week of the year | ||
|
|
||
| public static String toDisplayDate(Instant instant) { | ||
| return instant.atZone(DISPLAY_ZONE).toLocalDate().toString(); | ||
| } | ||
|
|
||
| public static List<YearMonth> getMonthsInRange(Instant startDate, Instant endDate) { | ||
| List<YearMonth> months = new ArrayList<>(); | ||
|
|
||
| YearMonth current = YearMonth.from(startDate.atZone(DISPLAY_ZONE)); | ||
| YearMonth last = YearMonth.from(endDate.atZone(DISPLAY_ZONE)); | ||
|
|
||
| while (!current.isAfter(last)) { | ||
| months.add(current); | ||
| current = current.plusMonths(1); | ||
| } | ||
|
|
||
| return months; | ||
| } | ||
|
|
||
| public static String getMonthName(int monthNum) { | ||
| return Month.of(monthNum).getDisplayName(TextStyle.SHORT, Locale.ENGLISH); | ||
| } | ||
|
|
||
| /** @param isoDayOfWeek ISO day-of-week (1 = Monday, 7 = Sunday) */ | ||
| public static String getDayName(int isoDayOfWeek) { | ||
| return DayOfWeek.of(isoDayOfWeek).getDisplayName(TextStyle.SHORT, Locale.ENGLISH); | ||
| } | ||
|
|
||
| public static int getIsoWeekCount(Instant instant) { | ||
| int year = instant.atZone(DISPLAY_ZONE).getYear(); | ||
| return LocalDate.of(year, DECEMBER, LAST_WEEK_DECEMBER_DAY).get(IsoFields.WEEK_OF_WEEK_BASED_YEAR); | ||
| } | ||
|
|
||
| public static long countMonths(Instant start, Instant end) { | ||
| YearMonth startMonth = YearMonth.from(start.atZone(ZoneOffset.UTC)); | ||
| YearMonth endMonth = YearMonth.from(end.atZone(ZoneOffset.UTC)); | ||
| return Math.max(1, startMonth.until(endMonth, ChronoUnit.MONTHS) + 1); | ||
| } | ||
|
|
||
| public static long countDaysBetween(Instant start, Instant end) { | ||
| return Math.max(1, ChronoUnit.DAYS.between(start, end) + 1); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| package com.exence.finance.config; | ||
|
|
||
| import org.springframework.context.annotation.Configuration; | ||
| import org.springframework.scheduling.annotation.EnableAsync; | ||
|
|
||
| @Configuration | ||
| @EnableAsync | ||
| public class AsyncConfig { | ||
| // Virtual thread executor is auto-configured by Spring Boot | ||
| // via spring.threads.virtual.enabled=true in application.yml | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| package com.exence.finance.modules.statistics.annotations; | ||
|
|
||
| import com.exence.finance.modules.statistics.validators.AtLeastOneNotEmptyValidator; | ||
| import jakarta.validation.Constraint; | ||
| import jakarta.validation.Payload; | ||
| import java.lang.annotation.Documented; | ||
| import java.lang.annotation.ElementType; | ||
| import java.lang.annotation.Retention; | ||
| import java.lang.annotation.RetentionPolicy; | ||
| import java.lang.annotation.Target; | ||
|
|
||
| @Target({ElementType.TYPE}) | ||
| @Retention(RetentionPolicy.RUNTIME) | ||
| @Constraint(validatedBy = AtLeastOneNotEmptyValidator.class) | ||
| @Documented | ||
| public @interface AtLeastOneNotEmpty { | ||
| String message() default "At least one of statCards or charts must be provided"; | ||
|
|
||
| Class<?>[] groups() default {}; | ||
|
|
||
| Class<? extends Payload>[] payload() default {}; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| package com.exence.finance.modules.statistics.annotations; | ||
|
|
||
| import com.exence.finance.modules.statistics.validators.WidgetLayoutValidator; | ||
| import jakarta.validation.Constraint; | ||
| import jakarta.validation.Payload; | ||
| import java.lang.annotation.Documented; | ||
| import java.lang.annotation.ElementType; | ||
| import java.lang.annotation.Retention; | ||
| import java.lang.annotation.RetentionPolicy; | ||
| import java.lang.annotation.Target; | ||
|
|
||
| @Target({ElementType.TYPE}) | ||
| @Retention(RetentionPolicy.RUNTIME) | ||
| @Constraint(validatedBy = WidgetLayoutValidator.class) | ||
| @Documented | ||
| public @interface ValidWidgetLayout { | ||
| String message() default | ||
| "Invalid widget layout: StatCard requires displayOrder only, graph widgets require position (x, y)" | ||
| + " fields"; | ||
|
|
||
| Class<?>[] groups() default {}; | ||
|
|
||
| Class<? extends Payload>[] payload() default {}; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| package com.exence.finance.modules.statistics.controller; | ||
|
|
||
| import com.exence.finance.modules.statistics.dto.Timeframe; | ||
| import com.exence.finance.modules.statistics.dto.UpdateLayoutRequest; | ||
| import com.exence.finance.modules.statistics.dto.WidgetDTO; | ||
| import com.exence.finance.modules.statistics.dto.response.WidgetDataResponse; | ||
| import com.exence.finance.modules.statistics.dto.response.WidgetLayoutResponse; | ||
| import org.springframework.http.ResponseEntity; | ||
|
|
||
| public interface WidgetController { | ||
|
|
||
| ResponseEntity<WidgetLayoutResponse> getLayout(); | ||
|
|
||
| ResponseEntity<WidgetDataResponse> getWidgetData(Long widgetId, Timeframe timeframe); | ||
|
|
||
| ResponseEntity<WidgetLayoutResponse> createWidget(WidgetDTO widgetDTO); | ||
|
|
||
| ResponseEntity<WidgetLayoutResponse> updateLayout(UpdateLayoutRequest request); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| package com.exence.finance.modules.statistics.controller.impl; | ||
|
|
||
| import com.exence.finance.common.util.ResponseFactory; | ||
| import com.exence.finance.modules.statistics.controller.WidgetController; | ||
| import com.exence.finance.modules.statistics.dto.Timeframe; | ||
| import com.exence.finance.modules.statistics.dto.UpdateLayoutRequest; | ||
| import com.exence.finance.modules.statistics.dto.WidgetDTO; | ||
| import com.exence.finance.modules.statistics.dto.response.WidgetDataResponse; | ||
| import com.exence.finance.modules.statistics.dto.response.WidgetLayoutResponse; | ||
| import com.exence.finance.modules.statistics.service.WidgetService; | ||
| import jakarta.validation.Valid; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.http.ResponseEntity; | ||
| import org.springframework.web.bind.annotation.CrossOrigin; | ||
| import org.springframework.web.bind.annotation.GetMapping; | ||
| import org.springframework.web.bind.annotation.PathVariable; | ||
| import org.springframework.web.bind.annotation.PostMapping; | ||
| import org.springframework.web.bind.annotation.PutMapping; | ||
| 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; | ||
|
|
||
| @RestController | ||
| @RequestMapping("/api/statistics/widgets") | ||
| @CrossOrigin(origins = "http://localhost:4200") | ||
| @RequiredArgsConstructor | ||
| public class WidgetControllerImpl implements WidgetController { | ||
|
|
||
| private final WidgetService widgetService; | ||
|
|
||
| @Override | ||
| @GetMapping("/layout") | ||
| public ResponseEntity<WidgetLayoutResponse> getLayout() { | ||
| return ResponseFactory.ok(widgetService.getLayout()); | ||
| } | ||
|
|
||
| @Override | ||
| @GetMapping("/{widgetId}/data") | ||
| public ResponseEntity<WidgetDataResponse> getWidgetData( | ||
| @PathVariable Long widgetId, @RequestParam(required = false) Timeframe timeframe) { | ||
| return ResponseFactory.ok(widgetService.getWidgetData(widgetId, timeframe)); | ||
| } | ||
|
|
||
| @Override | ||
| @PostMapping | ||
| public ResponseEntity<WidgetLayoutResponse> createWidget(@Valid @RequestBody WidgetDTO widgetDTO) { | ||
| return ResponseFactory.ok(widgetService.createWidget(widgetDTO)); | ||
| } | ||
|
|
||
| @Override | ||
| @PutMapping("/layout") | ||
| public ResponseEntity<WidgetLayoutResponse> updateLayout(@Valid @RequestBody UpdateLayoutRequest request) { | ||
| return ResponseFactory.ok(widgetService.updateLayout(request)); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| package com.exence.finance.modules.statistics.dto; | ||
|
|
||
| import jakarta.validation.constraints.NotNull; | ||
|
|
||
| public record ChartLayoutItem(@NotNull Long id, @NotNull Integer x, @NotNull Integer y) {} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| package com.exence.finance.modules.statistics.dto; | ||
|
|
||
| import jakarta.validation.constraints.NotNull; | ||
|
|
||
| public record StatCardLayoutItem(@NotNull Long id, @NotNull Integer displayOrder) {} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,78 @@ | ||
| package com.exence.finance.modules.statistics.dto; | ||
|
|
||
| import java.time.Instant; | ||
| import java.time.LocalDate; | ||
| import java.time.ZoneOffset; | ||
| import java.time.ZonedDateTime; | ||
| import java.time.temporal.ChronoUnit; | ||
|
|
||
| public enum Timeframe { | ||
| ONE_WEEK("1W", 7, ChronoUnit.DAYS), | ||
| ONE_MONTH("1M", 1, ChronoUnit.MONTHS), | ||
| THREE_MONTHS("3M", 3, ChronoUnit.MONTHS), | ||
| SIX_MONTHS("6M", 6, ChronoUnit.MONTHS), | ||
| ONE_YEAR("1Y", 1, ChronoUnit.YEARS), | ||
| YTD("YTD", 0, null), | ||
| ALL_TIME("ALL", Integer.MAX_VALUE, ChronoUnit.YEARS); | ||
|
|
||
| private final String code; | ||
| private final int amount; | ||
| private final ChronoUnit unit; | ||
|
|
||
| Timeframe(String code, int amount, ChronoUnit unit) { | ||
| this.code = code; | ||
| this.amount = amount; | ||
| this.unit = unit; | ||
| } | ||
|
|
||
| public Instant toStartDate() { | ||
| if (this == ALL_TIME) { | ||
| return Instant.EPOCH; | ||
| } | ||
| if (this == YTD) { | ||
| return LocalDate.now(ZoneOffset.UTC) | ||
| .withDayOfYear(1) | ||
| .atStartOfDay(ZoneOffset.UTC) | ||
| .toInstant(); | ||
| } | ||
| return ZonedDateTime.now(ZoneOffset.UTC).minus(amount, unit).toInstant(); | ||
| } | ||
|
|
||
| // fallback to 1y if null or unrecognized | ||
| public static Timeframe fromCode(String code) { | ||
| if (code == null) return YTD; | ||
| for (Timeframe t : values()) { | ||
| if (t.code.equalsIgnoreCase(code)) return t; | ||
|
Comment on lines
+41
to
+45
|
||
| } | ||
|
|
||
| return YTD; | ||
| } | ||
|
|
||
| /** | ||
| * Computes the start of the previous period for trend comparison. | ||
| * For YTD: same dates shifted back 1 year. | ||
| * For fixed durations: mirrors the duration before currentStart. | ||
| * All time has no previous period, so returns null. | ||
| */ | ||
| public Instant previousPeriodStart(Instant currentStart, Instant currentEnd) { | ||
| if (this == ALL_TIME) { | ||
| return null; | ||
| } | ||
| if (this == YTD) { | ||
| return currentStart.atZone(ZoneOffset.UTC).minusYears(1).toInstant(); | ||
| } | ||
| return ZonedDateTime.ofInstant(currentStart, ZoneOffset.UTC).minus(amount, unit).toInstant(); | ||
| } | ||
|
|
||
| /** | ||
| * Computes the end of the previous period for trend comparison. | ||
| * For YTD: same end date shifted back 1 year. | ||
| * For others: the current period's start. | ||
| */ | ||
| public Instant previousPeriodEnd(Instant currentStart, Instant currentEnd) { | ||
| if (this == YTD) { | ||
| return currentEnd.atZone(ZoneOffset.UTC).minusYears(1).toInstant(); | ||
| } | ||
| return currentStart; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| package com.exence.finance.modules.statistics.dto; | ||
|
|
||
| import com.exence.finance.modules.statistics.annotations.AtLeastOneNotEmpty; | ||
| import jakarta.validation.Valid; | ||
| import java.util.List; | ||
|
|
||
| @AtLeastOneNotEmpty | ||
| public record UpdateLayoutRequest(@Valid List<StatCardLayoutItem> statCards, @Valid List<ChartLayoutItem> charts) {} |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,16 @@ | ||||||
| package com.exence.finance.modules.statistics.dto; | ||||||
|
|
||||||
| import com.exence.finance.modules.statistics.annotations.ValidWidgetLayout; | ||||||
| import jakarta.validation.constraints.NotNull; | ||||||
| import java.util.Map; | ||||||
|
|
||||||
| @ValidWidgetLayout | ||||||
| public record WidgetDTO( | ||||||
| Long id, | ||||||
| @NotNull WidgetType type, | ||||||
| String title, | ||||||
|
||||||
| String title, | |
| @NotNull String title, |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| package com.exence.finance.modules.statistics.dto; | ||
|
|
||
| import java.time.Instant; | ||
| import java.util.Map; | ||
|
|
||
| public record WidgetRequest( | ||
| Instant startDate, Instant endDate, Timeframe timeframe, Map<WidgetSetting, Object> settings) {} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
timeframerequest param is bound directly to theTimeframeenum, so Spring will only accept values likeONE_WEEK, not the short codes (1W,YTD, etc.). If the API is meant to accept the codes, accept aStringhere and parse viaTimeframe.fromCode(...), or register aConverter<String, Timeframe>.