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
36 changes: 24 additions & 12 deletions src/main/java/ua/ndmik/bot/handler/AbstractAreaGroupHandler.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import ua.ndmik.bot.repository.ScheduleRepository;
import ua.ndmik.bot.repository.UserSettingsRepository;
import ua.ndmik.bot.service.TelegramService;
import ua.ndmik.bot.util.GroupIdComparator;

import java.util.List;

Expand Down Expand Up @@ -39,46 +40,53 @@ protected AbstractAreaGroupHandler(TelegramService telegramService,
@Override
public void handle(Update update) {
UserSettings user = requireUser(getChatId(update));
reprint(update, user.getGroupId(), GROUP_SELECTION_TEXT, targetArea(), 0);
reprint(update, user.getGroupId(), user.getArea(), GROUP_SELECTION_TEXT, targetArea(), 0);
}

public void reprint(Update update, String userGroupId, String text) {
public void reprint(Update update, String selectedGroupId, DtekArea selectedArea, String text) {
UserSettings user = requireUser(getChatId(update));
DtekArea area = user.getTmpArea() != null
? user.getTmpArea()
: (user.getArea() != null ? user.getArea() : DtekArea.KYIV_REGION);
reprint(update, userGroupId, text, area, 0);
reprint(update, selectedGroupId, selectedArea, text, area, 0);
}

public void reprint(Update update, String userGroupId, String text, DtekArea area, int page) {
public void reprint(Update update, String selectedGroupId, DtekArea selectedArea, String text, DtekArea area, int page) {
UserSettings user = requireUser(getChatId(update));
if (user.getTmpArea() != area) {
user.setTmpArea(area);
user.setTmpGroupId(null);
userRepository.save(user);
}
editGroupSelection(update, userGroupId, text, area, page);
editGroupSelection(update, selectedGroupId, selectedArea, text, area, page);
}

private void editGroupSelection(Update update, String selectedGroupId, String text, DtekArea area, int page) {
private void editGroupSelection(Update update,
String selectedGroupId,
DtekArea selectedArea,
String text,
DtekArea area,
int page) {
long chatId = getChatId(update);
List<String> groupIds = scheduleRepository.findGroupIdsByArea(area.name())
.stream()
.sorted()
.sorted(GroupIdComparator.INSTANCE)
.toList();
int totalPages = Math.max(1, (groupIds.size() + PAGE_SIZE - 1) / PAGE_SIZE);
int normalizedPage = Math.max(0, Math.min(page, totalPages - 1));
int fromIndex = normalizedPage * PAGE_SIZE;
int toIndex = Math.min(fromIndex + PAGE_SIZE, groupIds.size());
List<String> pageGroupIds = groupIds.subList(fromIndex, toIndex);

List<InlineKeyboardButton> buttons = pageGroupIds.stream()
.map(groupId -> telegramService.button(
formatButton(groupId, selectedGroupId),
formatButton(groupId, selectedGroupId, selectedArea, area),
GROUP_CLICK.name() + ":" + groupId + ":" + area.name() + ":" + normalizedPage)
)
.toList();
List<InlineKeyboardRow> rows = telegramService.chunkButtons(buttons, 2);
rows.add(buildPaginationRow(area, normalizedPage, totalPages));
if (totalPages > 1) {
rows.add(buildPaginationRow(area, normalizedPage, totalPages));
}
rows.add(new InlineKeyboardRow(List.of(
telegramService.button("⬅️ Назад", GROUP_BACK.name()),
telegramService.button("✅ Підтвердити", GROUP_DONE.name()))
Expand Down Expand Up @@ -110,12 +118,16 @@ private InlineKeyboardRow buildPaginationRow(DtekArea area, int page, int totalP
));
}

private String formatButton(String groupId, String userGroupId) {
return groupId.equals(userGroupId)
private String formatButton(String groupId, String selectedGroupId, DtekArea selectedArea, DtekArea area) {
return isSelectedForArea(groupId, selectedGroupId, selectedArea, area)
? "✅ " + groupId
: "⚪ " + groupId;
}

static boolean isSelectedForArea(String groupId, String selectedGroupId, DtekArea selectedArea, DtekArea area) {
return selectedArea == area && groupId.equals(selectedGroupId);
}

private UserSettings requireUser(long chatId) {
return userRepository.findByChatId(chatId)
.orElseThrow(() -> new RuntimeException(String.format("User not found for chatId=%s", chatId)));
Expand Down
1 change: 1 addition & 0 deletions src/main/java/ua/ndmik/bot/handler/GroupClickHandler.java
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ public void handle(Update update) {
regionHandler.reprint(
update,
payload.groupId(),
payload.area(),
"✅ Групу обрано. Натисніть «✅ Підтвердити», щоб зберегти вибір.",
payload.area(),
payload.page()
Expand Down
2 changes: 1 addition & 1 deletion src/main/java/ua/ndmik/bot/handler/GroupDoneHandler.java
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ public void handle(Update update) {
String groupId = user.getTmpGroupId();
DtekArea area = user.getTmpArea();
if (groupId == null) {
regionHandler.reprint(update, null, "⚠️ Щоб зберегти вибір, спочатку оберіть групу зі списку нижче.");
regionHandler.reprint(update, null, null, "⚠️ Щоб зберегти вибір, спочатку оберіть групу зі списку нижче.");
return;
}
user.setGroupId(groupId);
Expand Down
9 changes: 8 additions & 1 deletion src/main/java/ua/ndmik/bot/handler/GroupPageHandler.java
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,16 @@ public void handle(Update update) {
PagePayload payload = parsePayload(data);
UserSettings user = userRepository.findByChatId(chatId)
.orElseThrow(() -> new RuntimeException(String.format("User not found for chatId=%s", chatId)));
String selectedGroupId = user.getTmpGroupId() != null
? user.getTmpGroupId()
: user.getGroupId();
DtekArea selectedArea = user.getTmpGroupId() != null
? user.getTmpArea()
: user.getArea();
regionHandler.reprint(
update,
user.getTmpGroupId() != null ? user.getTmpGroupId() : user.getGroupId(),
selectedGroupId,
selectedArea,
AbstractAreaGroupHandler.GROUP_SELECTION_TEXT,
payload.area(),
payload.page()
Expand Down
5 changes: 2 additions & 3 deletions src/main/java/ua/ndmik/bot/repository/ScheduleRepository.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
import org.springframework.data.repository.query.Param;
import ua.ndmik.bot.model.entity.Schedule;
import ua.ndmik.bot.model.entity.ScheduleId;
import ua.ndmik.bot.model.entity.ScheduleDay;

import java.util.List;

Expand Down Expand Up @@ -43,15 +42,15 @@ public interface ScheduleRepository extends CrudRepository<Schedule, ScheduleId>
FROM schedules
WHERE schedule_day = :day
""", nativeQuery = true)
List<Schedule> findByDay(@Param("day") ScheduleDay scheduleDay);
List<Schedule> findByDay(@Param("day") String scheduleDay);

@Modifying
@Query(value = """
DELETE
FROM schedules
WHERE schedule_day = :day
""", nativeQuery = true)
void deleteByDay(@Param("day") ScheduleDay day);
void deleteByDay(@Param("day") String day);

@Query(value = """
SELECT DISTINCT group_id
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,9 @@ public MidnightRolloverScheduler(ScheduleRepository scheduleRepository) {
@Scheduled(cron = "0 0 0 * * *", zone = "${scheduler.shutdowns.time-zone:Europe/Kyiv}")
public void rolloverSchedulesAtMidnight() {
log.info("Running daily schedule rollover");
scheduleRepository.deleteByDay(TODAY);
List<Schedule> tomorrowSchedules = scheduleRepository.findByDay(TOMORROW);
scheduleRepository.deleteByDay(TOMORROW);
scheduleRepository.deleteByDay(TODAY.name());
List<Schedule> tomorrowSchedules = scheduleRepository.findByDay(TOMORROW.name());
scheduleRepository.deleteByDay(TOMORROW.name());
List<Schedule> rolledSchedules = tomorrowSchedules.stream()
.map(schedule -> Schedule.builder()
.area(schedule.getArea())
Expand Down
3 changes: 1 addition & 2 deletions src/main/java/ua/ndmik/bot/service/MessageFormatter.java
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,7 @@ public class MessageFormatter {
public String format(Map<LocalTime, LocalTime> todayShutdowns,
Map<LocalTime, LocalTime> tomorrowShutdowns,
LocalDate today) {
return "💡 <b>Коли буде світло</b>\n\n" +
formatDay("Сьогодні", today, todayShutdowns) +
return formatDay("Сьогодні", today, todayShutdowns) +
'\n' +
formatDay("Завтра", today.plusDays(1), tomorrowShutdowns);
}
Expand Down
23 changes: 19 additions & 4 deletions src/main/java/ua/ndmik/bot/service/TelegramService.java
Original file line number Diff line number Diff line change
Expand Up @@ -163,22 +163,27 @@ public void answerCallback(String callbackQueryId) {
public String formatMessage(UserSettings user, String header) {
String template = """
%s
%s

⚙️ <b>Ваші налаштування</b>

🏙️ Регіон: <b>%s</b>
🧩 Група відключень: <b>%s</b>
🔔 Сповіщення: <b>%s</b>

%s
""";
header = Strings.isNotBlank(header)
? (header + "\n")
: "";
String groupId = user.getGroupId();
DtekArea area = user.getArea();
String displayArea = formatAreaInfo(area);
String displayGroupId = formatGroupInfo(groupId);
String notificationStatus = formatNotificationInfo(user.isNotificationEnabled());
List<Schedule> schedules = scheduleRepository.findByGroupAndArea(groupId, user.getArea().name());
List<Schedule> schedules = (Strings.isBlank(groupId) || area == null)
? List.of()
: scheduleRepository.findByGroupAndArea(groupId, area.name());
String shutdowns = dtekService.getShutdownsMessage(schedules);
return String.format(template, header, displayGroupId, notificationStatus, shutdowns);
return String.format(template, header, shutdowns, displayArea, displayGroupId, notificationStatus);
}

private UserSettings getOrCreateUser(Update update) {
Expand Down Expand Up @@ -261,6 +266,16 @@ private String formatGroupInfo(String groupId) {
: groupId;
}

private String formatAreaInfo(DtekArea area) {
if (area == null) {
return "❗ Не обрано";
}
return switch (area) {
case KYIV -> "Київ";
case KYIV_REGION -> "Київщина";
};
}

private boolean isAdminChat(long chatId) {
return adminChatIds.contains(chatId);
}
Expand Down
64 changes: 64 additions & 0 deletions src/main/java/ua/ndmik/bot/util/GroupIdComparator.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package ua.ndmik.bot.util;

import java.math.BigInteger;
import java.util.Comparator;
import java.util.Objects;

public final class GroupIdComparator implements Comparator<String> {

public static final GroupIdComparator INSTANCE = new GroupIdComparator();

private GroupIdComparator() {
}

@Override
public int compare(String left, String right) {
if (Objects.equals(left, right)) {
return 0;
}
if (left == null) {
return 1;
}
if (right == null) {
return -1;
}

String[] leftParts = left.split("\\.", -1);
String[] rightParts = right.split("\\.", -1);
int partsToCompare = Math.min(leftParts.length, rightParts.length);

for (int i = 0; i < partsToCompare; i++) {
int comparison = comparePart(leftParts[i], rightParts[i]);
if (comparison != 0) {
return comparison;
}
}

int byLength = Integer.compare(leftParts.length, rightParts.length);
return byLength != 0
? byLength
: left.compareTo(right);
}

private int comparePart(String leftPart, String rightPart) {
boolean leftNumeric = isNumeric(leftPart);
boolean rightNumeric = isNumeric(rightPart);

if (leftNumeric && rightNumeric) {
BigInteger leftNumber = new BigInteger(leftPart);
BigInteger rightNumber = new BigInteger(rightPart);
int byNumber = leftNumber.compareTo(rightNumber);
return byNumber != 0
? byNumber
: Integer.compare(leftPart.length(), rightPart.length());
}
if (leftNumeric != rightNumeric) {
return leftNumeric ? -1 : 1;
}
return leftPart.compareTo(rightPart);
}

private boolean isNumeric(String value) {
return !value.isEmpty() && value.chars().allMatch(Character::isDigit);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package ua.ndmik.bot.handler;

import org.junit.jupiter.api.Test;
import ua.ndmik.bot.model.DtekArea;

import static org.assertj.core.api.Assertions.assertThat;

class AbstractAreaGroupHandlerTests {

@Test
void isSelectedForArea_returnsTrueWhenGroupAndAreaMatch() {
boolean selected = AbstractAreaGroupHandler.isSelectedForArea("1.1", "1.1", DtekArea.KYIV_REGION, DtekArea.KYIV_REGION);

assertThat(selected).isTrue();
}

@Test
void isSelectedForArea_returnsFalseWhenAreaDiffers() {
boolean selected = AbstractAreaGroupHandler.isSelectedForArea("1.1", "1.1", DtekArea.KYIV_REGION, DtekArea.KYIV);

assertThat(selected).isFalse();
}

@Test
void isSelectedForArea_returnsFalseWhenGroupDiffers() {
boolean selected = AbstractAreaGroupHandler.isSelectedForArea("2.1", "1.1", DtekArea.KYIV, DtekArea.KYIV);

assertThat(selected).isFalse();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ void rolloverSchedulesAtMidnight_promotesTomorrowScheduleAndResetsNotifications(
new HourOverride(11, HourState.NO)
));

given(scheduleRepository.findByDay(ScheduleDay.TOMORROW)).willReturn(List.of(tomorrow));
given(scheduleRepository.findByDay(ScheduleDay.TOMORROW.name())).willReturn(List.of(tomorrow));

scheduler.rolloverSchedulesAtMidnight();

Expand All @@ -59,12 +59,12 @@ void rolloverSchedulesAtMidnight_promotesTomorrowScheduleAndResetsNotifications(
void rolloverSchedulesAtMidnight_deletesDaysAndSavesNothingWhenTomorrowIsMissing() {
MidnightRolloverScheduler scheduler = new MidnightRolloverScheduler(scheduleRepository);

given(scheduleRepository.findByDay(ScheduleDay.TOMORROW)).willReturn(List.of());
given(scheduleRepository.findByDay(ScheduleDay.TOMORROW.name())).willReturn(List.of());

scheduler.rolloverSchedulesAtMidnight();

then(scheduleRepository).should().deleteByDay(ScheduleDay.TODAY);
then(scheduleRepository).should().deleteByDay(ScheduleDay.TOMORROW);
then(scheduleRepository).should().deleteByDay(ScheduleDay.TODAY.name());
then(scheduleRepository).should().deleteByDay(ScheduleDay.TOMORROW.name());
assertThat(capturedSavedSchedules()).isEmpty();
}

Expand Down
30 changes: 30 additions & 0 deletions src/test/java/ua/ndmik/bot/util/GroupIdComparatorTests.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package ua.ndmik.bot.util;

import org.junit.jupiter.api.Test;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

class GroupIdComparatorTests {

@Test
void sort_ordersDottedIdsByNumericParts() {
List<String> sorted = List.of("1.1", "10.1", "11.1", "12.1", "2.1", "3.1")
.stream()
.sorted(GroupIdComparator.INSTANCE)
.toList();

assertThat(sorted).containsExactly("1.1", "2.1", "3.1", "10.1", "11.1", "12.1");
}

@Test
void sort_comparesEachPartNumerically() {
List<String> sorted = List.of("1.10", "1.2", "1.2.1", "1.2.0")
.stream()
.sorted(GroupIdComparator.INSTANCE)
.toList();

assertThat(sorted).containsExactly("1.2", "1.2.0", "1.2.1", "1.10");
}
}