From 590f11a51539b95db03257d7f3f242ad59927a58 Mon Sep 17 00:00:00 2001 From: gr1shan1a <368409@edu.itmo.ru> Date: Wed, 25 Feb 2026 19:43:41 +0300 Subject: [PATCH 01/15] <25.02.2026 19:01> --- .../controller/MiniAppMeetingController.java | 57 +++++++++++++++++-- .../java/com/aichef/domain/model/Meeting.java | 3 + .../aichef/service/TelegramBotService.java | 1 + db/schema.sql | 1 + frontend/src/main/resources/static/app.js | 47 +++++++++++++-- frontend/src/main/resources/static/index.html | 4 ++ frontend/src/main/resources/static/styles.css | 25 ++++++-- 7 files changed, 125 insertions(+), 13 deletions(-) diff --git a/backend-core/src/main/java/com/aichef/controller/MiniAppMeetingController.java b/backend-core/src/main/java/com/aichef/controller/MiniAppMeetingController.java index 3f54977..3d32b7c 100644 --- a/backend-core/src/main/java/com/aichef/controller/MiniAppMeetingController.java +++ b/backend-core/src/main/java/com/aichef/controller/MiniAppMeetingController.java @@ -16,16 +16,18 @@ import java.time.LocalDate; import java.time.OffsetDateTime; -import java.time.ZoneId; import java.util.List; import java.util.Optional; import java.util.UUID; +import java.util.regex.Pattern; @RestController @Slf4j @RequiredArgsConstructor @RequestMapping("/api/miniapp/meetings") public class MiniAppMeetingController { + private static final Pattern HEX_COLOR_PATTERN = Pattern.compile("^#([0-9a-fA-F]{6})$"); + private static final String DEFAULT_MEETING_COLOR = "#93c5fd"; private final MiniAppAuthService miniAppAuthService; private final MeetingRepository meetingRepository; @@ -72,6 +74,10 @@ public ResponseEntity create( || request.startsAt() == null || request.endsAt() == null) { return ResponseEntity.badRequest().body("Missing required fields"); } + String normalizedColor = normalizeHexColor(request.color()); + if (request.color() != null && normalizedColor == null) { + return ResponseEntity.badRequest().body("Invalid color"); + } User user = userOpt.get(); Meeting meeting = new Meeting(); meeting.setTitle(TextNormalization.normalizeRussian(request.title().trim())); @@ -79,6 +85,7 @@ public ResponseEntity create( meeting.setEndsAt(request.endsAt()); meeting.setLocation(request.location()); meeting.setExternalLink(request.externalLink()); + meeting.setColor(normalizedColor == null ? DEFAULT_MEETING_COLOR : normalizedColor); meeting.setStatus(MeetingStatus.CONFIRMED); meeting.setCalendarDay(getOrCreateDay(user, request.startsAt().toLocalDate())); meetingRepository.save(meeting); @@ -86,11 +93,30 @@ public ResponseEntity create( } @PatchMapping("/{id}") + public ResponseEntity patch( + @PathVariable("id") UUID id, + @RequestBody MeetingUpdateRequest request, + @RequestHeader(value = "X-Telegram-Init-Data", required = false) String initData, + @RequestParam(value = "telegramId", required = false) Long telegramId + ) { + return updateInternal(id, request, initData, telegramId); + } + + @PutMapping("/{id}") public ResponseEntity update( @PathVariable("id") UUID id, @RequestBody MeetingUpdateRequest request, @RequestHeader(value = "X-Telegram-Init-Data", required = false) String initData, @RequestParam(value = "telegramId", required = false) Long telegramId + ) { + return updateInternal(id, request, initData, telegramId); + } + + private ResponseEntity updateInternal( + UUID id, + MeetingUpdateRequest request, + String initData, + Long telegramId ) { Optional userOpt = miniAppAuthService.resolveUser(initData, telegramId); if (userOpt.isEmpty()) { @@ -103,6 +129,10 @@ public ResponseEntity update( || !meeting.getCalendarDay().getUser().getId().equals(user.getId())) { return ResponseEntity.status(HttpStatus.NOT_FOUND).body("Meeting not found"); } + String normalizedColor = normalizeHexColor(request.color()); + if (request.color() != null && normalizedColor == null) { + return ResponseEntity.badRequest().body("Invalid color"); + } if (request.title() != null) { String title = TextNormalization.normalizeRussian(request.title().trim()); @@ -123,7 +153,12 @@ public ResponseEntity update( if (request.externalLink() != null) { meeting.setExternalLink(request.externalLink()); } + if (request.color() != null) { + meeting.setColor(normalizedColor); + } meetingRepository.save(meeting); + log.info("MiniApp meeting updated. userId={}, telegramId={}, meetingId={}, startsAt={}, endsAt={}, color={}", + user.getId(), user.getTelegramId(), meeting.getId(), meeting.getStartsAt(), meeting.getEndsAt(), meeting.getColor()); return ResponseEntity.ok(MeetingDto.from(meeting)); } @@ -166,7 +201,8 @@ public record MeetingDto( OffsetDateTime startsAt, OffsetDateTime endsAt, String location, - String externalLink + String externalLink, + String color ) { public static MeetingDto from(Meeting meeting) { return new MeetingDto( @@ -175,7 +211,8 @@ public static MeetingDto from(Meeting meeting) { meeting.getStartsAt(), meeting.getEndsAt(), TextNormalization.normalizeRussian(meeting.getLocation()), - TextNormalization.normalizeRussian(meeting.getExternalLink()) + TextNormalization.normalizeRussian(meeting.getExternalLink()), + TextNormalization.normalizeRussian(meeting.getColor()) ); } } @@ -185,7 +222,19 @@ public record MeetingUpdateRequest( OffsetDateTime startsAt, OffsetDateTime endsAt, String location, - String externalLink + String externalLink, + String color ) { } + + private String normalizeHexColor(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + if (trimmed.isBlank()) { + return null; + } + return HEX_COLOR_PATTERN.matcher(trimmed).matches() ? trimmed.toLowerCase() : null; + } } diff --git a/backend-core/src/main/java/com/aichef/domain/model/Meeting.java b/backend-core/src/main/java/com/aichef/domain/model/Meeting.java index a03a5f0..bf28b2c 100644 --- a/backend-core/src/main/java/com/aichef/domain/model/Meeting.java +++ b/backend-core/src/main/java/com/aichef/domain/model/Meeting.java @@ -42,6 +42,9 @@ public class Meeting extends BaseEntity { @Column(name = "external_link") private String externalLink; + @Column(name = "color") + private String color; + @Column(name = "google_event_id") private String googleEventId; diff --git a/backend-core/src/main/java/com/aichef/service/TelegramBotService.java b/backend-core/src/main/java/com/aichef/service/TelegramBotService.java index 1b7c174..e11d826 100644 --- a/backend-core/src/main/java/com/aichef/service/TelegramBotService.java +++ b/backend-core/src/main/java/com/aichef/service/TelegramBotService.java @@ -735,6 +735,7 @@ private Meeting createMeetingWithReminder( meeting.setStartsAt(startsAt); meeting.setEndsAt(endsAt); meeting.setExternalLink(externalLink); + meeting.setColor("#93c5fd"); meeting.setStatus(MeetingStatus.CONFIRMED); GoogleCalendarService.CreatedGoogleEvent googleEvent = googleCalendarService.createEvent( diff --git a/db/schema.sql b/db/schema.sql index 229d315..676b44f 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -95,6 +95,7 @@ CREATE TABLE meetings ( ends_at TIMESTAMPTZ NOT NULL, location TEXT NULL, external_link TEXT NULL, + color TEXT NULL, google_event_id TEXT NULL, status TEXT NOT NULL DEFAULT 'confirmed' CHECK (status IN ('tentative', 'confirmed', 'canceled')), created_at TIMESTAMP NOT NULL DEFAULT now(), diff --git a/frontend/src/main/resources/static/app.js b/frontend/src/main/resources/static/app.js index 88d031b..a87d132 100644 --- a/frontend/src/main/resources/static/app.js +++ b/frontend/src/main/resources/static/app.js @@ -36,7 +36,8 @@ eventEditTitle: document.getElementById("eventEditTitle"), eventEditDate: document.getElementById("eventEditDate"), eventEditTime: document.getElementById("eventEditTime"), - eventEditDuration: document.getElementById("eventEditDuration") + eventEditDuration: document.getElementById("eventEditDuration"), + eventEditColor: document.getElementById("eventEditColor") }; const state = { @@ -348,6 +349,7 @@ const pill = document.createElement("button"); pill.type = "button"; pill.className = "meeting-pill"; + applyMeetingColor(pill, meeting.color); pill.style.top = `${top}px`; pill.style.left = `${left}px`; pill.style.width = `${width}px`; @@ -402,6 +404,9 @@ const mins = isValidDate(startsAt) && isValidDate(endsAt) ? Math.max(5, Math.round((endsAt - startsAt) / 60000)) : 60; el.eventEditDuration.value = String(mins); } + if (el.eventEditColor) { + el.eventEditColor.value = normalizeHexColor(meeting.color) || "#93c5fd"; + } } function setEditMode(enabled) { @@ -426,10 +431,15 @@ const date = String(el.eventEditDate && el.eventEditDate.value ? el.eventEditDate.value : "").trim(); const time = String(el.eventEditTime && el.eventEditTime.value ? el.eventEditTime.value : "").trim(); const duration = Number(el.eventEditDuration && el.eventEditDuration.value ? el.eventEditDuration.value : 0); + const color = normalizeHexColor(el.eventEditColor && el.eventEditColor.value ? el.eventEditColor.value : ""); if (!title || !date || !time || !Number.isFinite(duration) || duration <= 0) { setEventModalStatus("Проверьте название, дату, время и длительность"); return; } + if (!color) { + setEventModalStatus("Некорректный цвет"); + return; + } const startsAt = parseLocalDateTime(date, time); if (!startsAt) { @@ -440,16 +450,21 @@ const payload = { title, startsAt: toOffsetIso(startsAt), - endsAt: toOffsetIso(endsAt) + endsAt: toOffsetIso(endsAt), + color }; setEventModalStatus("Сохраняю..."); const res = await requestWithFallback( getMeetingEndpoints().map((base) => `${base}/${activeMeeting.id}`), - [{ method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }] + [ + { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }, + { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) } + ] ); if (!res.success) { - setEventModalStatus(res.message || "Ошибка сохранения"); + const detail = res.status ? ` (HTTP ${res.status})` : ""; + setEventModalStatus((res.message || "Ошибка сохранения, проверь доступ и формат даты/времени") + detail); return; } @@ -782,6 +797,30 @@ return s[0].toUpperCase() + s.slice(1); } + function normalizeHexColor(value) { + const v = String(value || "").trim(); + return /^#([0-9a-fA-F]{6})$/.test(v) ? v.toLowerCase() : ""; + } + + function applyMeetingColor(element, color) { + if (!element) return; + const safe = normalizeHexColor(color) || "#93c5fd"; + element.style.borderColor = safe; + element.style.backgroundColor = withAlpha(safe, 0.3); + element.style.color = "#1e3a8a"; + } + + function withAlpha(hex, alpha) { + const safe = normalizeHexColor(hex); + if (!safe) return "rgba(147,197,253,0.3)"; + const raw = safe.slice(1); + const r = parseInt(raw.slice(0, 2), 16); + const g = parseInt(raw.slice(2, 4), 16); + const b = parseInt(raw.slice(4, 6), 16); + const a = Number.isFinite(alpha) ? Math.max(0, Math.min(1, alpha)) : 0.3; + return `rgba(${r}, ${g}, ${b}, ${a})`; + } + function isValidDate(value) { return value instanceof Date && !Number.isNaN(value.getTime()); } diff --git a/frontend/src/main/resources/static/index.html b/frontend/src/main/resources/static/index.html index 498cf6e..664c3c9 100644 --- a/frontend/src/main/resources/static/index.html +++ b/frontend/src/main/resources/static/index.html @@ -73,6 +73,10 @@

AiCal

Длительность (мин) + diff --git a/frontend/src/main/resources/static/styles.css b/frontend/src/main/resources/static/styles.css index b4f9fcd..ca74062 100644 --- a/frontend/src/main/resources/static/styles.css +++ b/frontend/src/main/resources/static/styles.css @@ -325,15 +325,23 @@ body.sidebar-open .sidebar-backdrop { cursor: pointer; font: inherit; overflow: hidden; + align-items: flex-start; + justify-content: flex-start; } .meeting-pill-title { - font-size: 11px; - line-height: 1.1; + font-size: 10px; + line-height: 1.05; font-weight: 600; - white-space: nowrap; + white-space: normal; + word-break: break-word; + hyphens: auto; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 12; overflow: hidden; - text-overflow: ellipsis; + text-overflow: clip; + width: 100%; } .now-time-line { @@ -632,6 +640,13 @@ body.sidebar-open .sidebar-backdrop { font: inherit; } +.event-color-field input[type="color"] { + padding: 0; + width: 58px; + height: 40px; + border-radius: 8px; +} + @media (max-width: 980px) { :root { --sidebar-width: 220px; @@ -652,7 +667,7 @@ body.sidebar-open .sidebar-backdrop { .time-label { font-size: 12px; } .user-text .name { font-size: 12px; } .user-text .id { font-size: 11px; } - .meeting-pill-title { font-size: 10px; } + .meeting-pill-title { font-size: 9px; } .event-modal-title { font-size: 22px; } .event-modal-datetime { font-size: 15px; } } From ad2bb6f2fa4a46321ddeb9fcebe5eb1e31deff7b Mon Sep 17 00:00:00 2001 From: gr1shan1a <368409@edu.itmo.ru> Date: Wed, 25 Feb 2026 20:07:46 +0300 Subject: [PATCH 02/15] <25.02.2026 20:07> --- .../controller/MiniAppMeetingController.java | 49 +++++++++- frontend/src/main/resources/static/app.js | 93 ++++++++++++++++++- frontend/src/main/resources/static/index.html | 9 +- frontend/src/main/resources/static/styles.css | 44 +++++++++ 4 files changed, 190 insertions(+), 5 deletions(-) diff --git a/backend-core/src/main/java/com/aichef/controller/MiniAppMeetingController.java b/backend-core/src/main/java/com/aichef/controller/MiniAppMeetingController.java index 3d32b7c..5949965 100644 --- a/backend-core/src/main/java/com/aichef/controller/MiniAppMeetingController.java +++ b/backend-core/src/main/java/com/aichef/controller/MiniAppMeetingController.java @@ -12,6 +12,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.web.bind.annotation.*; import java.time.LocalDate; @@ -74,6 +75,9 @@ public ResponseEntity create( || request.startsAt() == null || request.endsAt() == null) { return ResponseEntity.badRequest().body("Missing required fields"); } + if (!request.endsAt().isAfter(request.startsAt())) { + return ResponseEntity.badRequest().body("endsAt must be after startsAt"); + } String normalizedColor = normalizeHexColor(request.color()); if (request.color() != null && normalizedColor == null) { return ResponseEntity.badRequest().body("Invalid color"); @@ -88,7 +92,19 @@ public ResponseEntity create( meeting.setColor(normalizedColor == null ? DEFAULT_MEETING_COLOR : normalizedColor); meeting.setStatus(MeetingStatus.CONFIRMED); meeting.setCalendarDay(getOrCreateDay(user, request.startsAt().toLocalDate())); - meetingRepository.save(meeting); + try { + meetingRepository.save(meeting); + } catch (DataIntegrityViolationException e) { + log.error("MiniApp meeting create DB constraint error. userId={}, telegramId={}, title={}, startsAt={}, endsAt={}, color={}, error={}", + user.getId(), user.getTelegramId(), request.title(), request.startsAt(), request.endsAt(), normalizedColor, e.getMessage(), e); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("DB constraint error"); + } catch (Exception e) { + log.error("MiniApp meeting create failed. userId={}, telegramId={}, title={}, startsAt={}, endsAt={}, color={}, error={}", + user.getId(), user.getTelegramId(), request.title(), request.startsAt(), request.endsAt(), normalizedColor, e.getMessage(), e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Internal error"); + } + log.info("MiniApp meeting created. userId={}, telegramId={}, meetingId={}, startsAt={}, endsAt={}, color={}", + user.getId(), user.getTelegramId(), meeting.getId(), meeting.getStartsAt(), meeting.getEndsAt(), meeting.getColor()); return ResponseEntity.ok(MeetingDto.from(meeting)); } @@ -120,6 +136,8 @@ private ResponseEntity updateInternal( ) { Optional userOpt = miniAppAuthService.resolveUser(initData, telegramId); if (userOpt.isEmpty()) { + log.warn("MiniApp meeting update unauthorized. meetingId={}, telegramIdParam={}, hasInitData={}", + id, telegramId, initData != null && !initData.isBlank()); return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Unauthorized"); } User user = userOpt.get(); @@ -127,6 +145,8 @@ private ResponseEntity updateInternal( if (meeting == null || meeting.getCalendarDay() == null || meeting.getCalendarDay().getUser() == null || !meeting.getCalendarDay().getUser().getId().equals(user.getId())) { + log.warn("MiniApp meeting update not found/forbidden. meetingId={}, userId={}, telegramId={}", + id, user.getId(), user.getTelegramId()); return ResponseEntity.status(HttpStatus.NOT_FOUND).body("Meeting not found"); } String normalizedColor = normalizeHexColor(request.color()); @@ -156,7 +176,22 @@ private ResponseEntity updateInternal( if (request.color() != null) { meeting.setColor(normalizedColor); } - meetingRepository.save(meeting); + OffsetDateTime startsAt = meeting.getStartsAt(); + OffsetDateTime endsAt = meeting.getEndsAt(); + if (startsAt == null || endsAt == null || !endsAt.isAfter(startsAt)) { + return ResponseEntity.badRequest().body("endsAt must be after startsAt"); + } + try { + meetingRepository.save(meeting); + } catch (DataIntegrityViolationException e) { + log.error("MiniApp meeting update DB constraint error. userId={}, telegramId={}, meetingId={}, startsAt={}, endsAt={}, color={}, error={}", + user.getId(), user.getTelegramId(), meeting.getId(), meeting.getStartsAt(), meeting.getEndsAt(), meeting.getColor(), e.getMessage(), e); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("DB constraint error"); + } catch (Exception e) { + log.error("MiniApp meeting update failed. userId={}, telegramId={}, meetingId={}, startsAt={}, endsAt={}, color={}, error={}", + user.getId(), user.getTelegramId(), meeting.getId(), meeting.getStartsAt(), meeting.getEndsAt(), meeting.getColor(), e.getMessage(), e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Internal error"); + } log.info("MiniApp meeting updated. userId={}, telegramId={}, meetingId={}, startsAt={}, endsAt={}, color={}", user.getId(), user.getTelegramId(), meeting.getId(), meeting.getStartsAt(), meeting.getEndsAt(), meeting.getColor()); return ResponseEntity.ok(MeetingDto.from(meeting)); @@ -180,7 +215,15 @@ public ResponseEntity delete( return ResponseEntity.status(HttpStatus.NOT_FOUND).body("Meeting not found"); } meeting.setStatus(MeetingStatus.CANCELED); - meetingRepository.save(meeting); + try { + meetingRepository.save(meeting); + } catch (Exception e) { + log.error("MiniApp meeting delete failed. userId={}, telegramId={}, meetingId={}, error={}", + user.getId(), user.getTelegramId(), meeting.getId(), e.getMessage(), e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Internal error"); + } + log.info("MiniApp meeting deleted(canceled). userId={}, telegramId={}, meetingId={}", + user.getId(), user.getTelegramId(), meeting.getId()); return ResponseEntity.noContent().build(); } diff --git a/frontend/src/main/resources/static/app.js b/frontend/src/main/resources/static/app.js index a87d132..88a2ee5 100644 --- a/frontend/src/main/resources/static/app.js +++ b/frontend/src/main/resources/static/app.js @@ -28,6 +28,10 @@ scheduleStatus: document.getElementById("scheduleStatus"), eventModal: document.getElementById("eventModal"), eventCloseBtn: document.getElementById("eventCloseBtn"), + eventMoreBtn: document.getElementById("eventMoreBtn"), + eventMoreMenu: document.getElementById("eventMoreMenu"), + eventDuplicateBtn: document.getElementById("eventDuplicateBtn"), + eventDeleteBtn: document.getElementById("eventDeleteBtn"), eventEditBtn: document.getElementById("eventEditBtn"), eventTitle: document.getElementById("eventTitle"), eventDateTime: document.getElementById("eventDateTime"), @@ -106,6 +110,18 @@ } }); } + if (el.eventMoreBtn) { + el.eventMoreBtn.addEventListener("click", () => { + if (!el.eventMoreMenu) return; + el.eventMoreMenu.classList.toggle("hidden"); + }); + } + if (el.eventDuplicateBtn) { + el.eventDuplicateBtn.addEventListener("click", onDuplicateMeeting); + } + if (el.eventDeleteBtn) { + el.eventDeleteBtn.addEventListener("click", onDeleteMeeting); + } if (el.eventEditBtn) { el.eventEditBtn.addEventListener("click", () => { const nextMode = !state.editMode; @@ -120,6 +136,13 @@ closeEventModal(); } }); + document.addEventListener("click", (e) => { + if (!el.eventMoreMenu || !el.eventMoreBtn) return; + if (el.eventMoreMenu.classList.contains("hidden")) return; + const target = e.target; + if (target instanceof Node && (el.eventMoreMenu.contains(target) || el.eventMoreBtn.contains(target))) return; + el.eventMoreMenu.classList.add("hidden"); + }); } function renderMenu() { @@ -378,6 +401,7 @@ if (!el.eventModal) return; el.eventModal.classList.add("hidden"); state.activeMeetingId = ""; + if (el.eventMoreMenu) el.eventMoreMenu.classList.add("hidden"); setEventModalStatus(""); } @@ -477,6 +501,70 @@ } } + async function onDuplicateMeeting() { + const activeMeeting = state.meetings.find((m) => String(m.id) === String(state.activeMeetingId)); + if (!activeMeeting) { + setEventModalStatus("Событие не найдено"); + return; + } + if (el.eventMoreMenu) el.eventMoreMenu.classList.add("hidden"); + + const startsAt = new Date(activeMeeting.startsAt); + const endsAt = new Date(activeMeeting.endsAt); + if (!isValidDate(startsAt) || !isValidDate(endsAt)) { + setEventModalStatus("Не удалось прочитать дату события"); + return; + } + const payload = { + title: String(activeMeeting.title || "Событие"), + startsAt: toOffsetIso(startsAt), + endsAt: toOffsetIso(endsAt), + location: activeMeeting.location || null, + externalLink: activeMeeting.externalLink || null, + color: normalizeHexColor(activeMeeting.color) || "#93c5fd" + }; + + setEventModalStatus("Дублирую..."); + const res = await requestWithFallback( + getMeetingEndpoints(), + [{ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }] + ); + if (!res.success) { + const detail = res.status ? ` (HTTP ${res.status})` : ""; + setEventModalStatus((res.message || "Ошибка дублирования") + detail); + return; + } + + setEventModalStatus("Событие продублировано"); + await loadMeetingsAndRender(); + } + + async function onDeleteMeeting() { + const activeMeeting = state.meetings.find((m) => String(m.id) === String(state.activeMeetingId)); + if (!activeMeeting) { + setEventModalStatus("Событие не найдено"); + return; + } + if (el.eventMoreMenu) el.eventMoreMenu.classList.add("hidden"); + const ok = window.confirm("Удалить это мероприятие?"); + if (!ok) return; + + setEventModalStatus("Удаляю..."); + const res = await requestWithFallback( + getMeetingEndpoints().map((base) => `${base}/${activeMeeting.id}`), + [{ method: "DELETE" }] + ); + if (!res.success) { + const detail = res.status ? ` (HTTP ${res.status})` : ""; + setEventModalStatus((res.message || "Ошибка удаления") + detail); + return; + } + + closeEventModal(); + await loadMeetingsAndRender(); + setScheduleStatus("Событие удалено"); + } + function setEventModalStatus(message) { if (el.eventModalStatus) { el.eventModalStatus.textContent = message || ""; @@ -539,7 +627,10 @@ API_REQUEST_TIMEOUT_MS ); if (!response.ok) { - return { success: false, status: response.status, message: apiErrorMessage(response.status) }; + const text = await response.text().catch(() => ""); + const baseMessage = apiErrorMessage(response.status); + const details = text && text.length < 240 ? `: ${text}` : ""; + return { success: false, status: response.status, message: `${baseMessage}${details}` }; } if (response.status === 204) { return { success: true, status: response.status, data: null }; diff --git a/frontend/src/main/resources/static/index.html b/frontend/src/main/resources/static/index.html index 664c3c9..c53d29b 100644 --- a/frontend/src/main/resources/static/index.html +++ b/frontend/src/main/resources/static/index.html @@ -50,7 +50,14 @@

AiCal

- +
+ + + +
diff --git a/frontend/src/main/resources/static/styles.css b/frontend/src/main/resources/static/styles.css index ca74062..ce28ecc 100644 --- a/frontend/src/main/resources/static/styles.css +++ b/frontend/src/main/resources/static/styles.css @@ -572,7 +572,15 @@ body.sidebar-open .sidebar-backdrop { border-bottom: 1px solid var(--border); } +.event-head-actions { + display: flex; + align-items: center; + gap: 8px; + position: relative; +} + .event-close-btn, +.event-more-btn, .event-edit-btn { width: 38px; height: 38px; @@ -589,6 +597,42 @@ body.sidebar-open .sidebar-backdrop { background: var(--accent-soft); } +.event-more-menu { + position: absolute; + right: 46px; + top: 44px; + min-width: 160px; + background: #fff; + border: 1px solid var(--border); + border-radius: 10px; + box-shadow: 0 8px 24px rgba(2, 6, 23, 0.14); + overflow: hidden; + z-index: 3; +} + +.event-more-menu.hidden { + display: none; +} + +.event-more-menu button { + width: 100%; + text-align: left; + border: 0; + background: #fff; + padding: 10px 12px; + font: inherit; + font-size: 14px; + color: #334155; +} + +.event-more-menu button + button { + border-top: 1px solid var(--border); +} + +.event-more-menu button.danger { + color: #b91c1c; +} + .event-modal-body { padding: 20px 16px 24px; overflow: auto; From 4bd7cb819ad2363e203fe47a9efa48372b300af3 Mon Sep 17 00:00:00 2001 From: gr1shan1a <368409@edu.itmo.ru> Date: Wed, 25 Feb 2026 20:29:56 +0300 Subject: [PATCH 03/15] <25.02.2026 20:29> --- .../controller/MiniAppMeetingController.java | 13 ++++ frontend/src/main/resources/static/app.js | 77 +++++++++---------- frontend/src/main/resources/static/index.html | 2 +- frontend/src/main/resources/static/styles.css | 6 +- 4 files changed, 53 insertions(+), 45 deletions(-) diff --git a/backend-core/src/main/java/com/aichef/controller/MiniAppMeetingController.java b/backend-core/src/main/java/com/aichef/controller/MiniAppMeetingController.java index 5949965..a0fd1d4 100644 --- a/backend-core/src/main/java/com/aichef/controller/MiniAppMeetingController.java +++ b/backend-core/src/main/java/com/aichef/controller/MiniAppMeetingController.java @@ -13,6 +13,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.web.bind.annotation.*; import java.time.LocalDate; @@ -280,4 +281,16 @@ private String normalizeHexColor(String value) { } return HEX_COLOR_PATTERN.matcher(trimmed).matches() ? trimmed.toLowerCase() : null; } + + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity handleTypeMismatch(MethodArgumentTypeMismatchException e) { + log.error("MiniApp meeting request type mismatch. message={}", e.getMessage(), e); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Invalid request format"); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleUnhandled(Exception e) { + log.error("MiniApp meeting controller unhandled error. message={}", e.getMessage(), e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Internal error"); + } } diff --git a/frontend/src/main/resources/static/app.js b/frontend/src/main/resources/static/app.js index 88a2ee5..16a4fb6 100644 --- a/frontend/src/main/resources/static/app.js +++ b/frontend/src/main/resources/static/app.js @@ -41,7 +41,8 @@ eventEditDate: document.getElementById("eventEditDate"), eventEditTime: document.getElementById("eventEditTime"), eventEditDuration: document.getElementById("eventEditDuration"), - eventEditColor: document.getElementById("eventEditColor") + eventEditColor: document.getElementById("eventEditColor"), + eventSubmitBtn: document.getElementById("eventSubmitBtn") }; const state = { @@ -49,7 +50,8 @@ meetings: [], nowTimer: null, activeMeetingId: "", - editMode: false + editMode: false, + formMode: "edit" }; init(); @@ -391,6 +393,7 @@ if (!meeting || !el.eventModal) return; state.activeMeetingId = String(meeting.id || ""); state.editMode = false; + state.formMode = "edit"; fillEventModal(meeting); setEventModalStatus(""); setEditMode(false); @@ -401,6 +404,7 @@ if (!el.eventModal) return; el.eventModal.classList.add("hidden"); state.activeMeetingId = ""; + state.formMode = "edit"; if (el.eventMoreMenu) el.eventMoreMenu.classList.add("hidden"); setEventModalStatus(""); } @@ -435,6 +439,7 @@ function setEditMode(enabled) { state.editMode = Boolean(enabled); + updateSubmitButtonText(); if (el.eventEditForm) { el.eventEditForm.classList.toggle("hidden", !state.editMode); } @@ -479,24 +484,38 @@ }; setEventModalStatus("Сохраняю..."); - const res = await requestWithFallback( - getMeetingEndpoints().map((base) => `${base}/${activeMeeting.id}`), - [ - { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }, - { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) } - ] - ); + const saveTargets = state.formMode === "duplicate" + ? getMeetingEndpoints() + : getMeetingEndpoints().map((base) => `${base}/${activeMeeting.id}`); + const saveVariants = state.formMode === "duplicate" + ? [{ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }] + : [ + { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }, + { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) } + ]; + + const res = await requestWithFallback(saveTargets, saveVariants); if (!res.success) { const detail = res.status ? ` (HTTP ${res.status})` : ""; setEventModalStatus((res.message || "Ошибка сохранения, проверь доступ и формат даты/времени") + detail); return; } - setEventModalStatus("Сохранено"); + const wasDuplicate = state.formMode === "duplicate"; + if (wasDuplicate) { + setEventModalStatus("Копия создана"); + state.formMode = "edit"; + } else { + setEventModalStatus("Сохранено"); + } setEditMode(false); await loadMeetingsAndRender(); - const updated = state.meetings.find((m) => String(m.id) === String(activeMeeting.id)); + const focusId = wasDuplicate + ? String(res.data && res.data.id ? res.data.id : activeMeeting.id) + : String(activeMeeting.id); + const updated = state.meetings.find((m) => String(m.id) === focusId); if (updated) { + state.activeMeetingId = String(updated.id || ""); fillEventModal(updated); } } @@ -509,34 +528,9 @@ } if (el.eventMoreMenu) el.eventMoreMenu.classList.add("hidden"); - const startsAt = new Date(activeMeeting.startsAt); - const endsAt = new Date(activeMeeting.endsAt); - if (!isValidDate(startsAt) || !isValidDate(endsAt)) { - setEventModalStatus("Не удалось прочитать дату события"); - return; - } - const payload = { - title: String(activeMeeting.title || "Событие"), - startsAt: toOffsetIso(startsAt), - endsAt: toOffsetIso(endsAt), - location: activeMeeting.location || null, - externalLink: activeMeeting.externalLink || null, - color: normalizeHexColor(activeMeeting.color) || "#93c5fd" - }; - - setEventModalStatus("Дублирую..."); - const res = await requestWithFallback( - getMeetingEndpoints(), - [{ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }] - ); - if (!res.success) { - const detail = res.status ? ` (HTTP ${res.status})` : ""; - setEventModalStatus((res.message || "Ошибка дублирования") + detail); - return; - } - - setEventModalStatus("Событие продублировано"); - await loadMeetingsAndRender(); + state.formMode = "duplicate"; + setEditMode(true); + setEventModalStatus("Режим дублирования: укажите новое название, дату и время, затем сохраните."); } async function onDeleteMeeting() { @@ -571,6 +565,11 @@ } } + function updateSubmitButtonText() { + if (!el.eventSubmitBtn) return; + el.eventSubmitBtn.textContent = state.formMode === "duplicate" ? "Создать копию" : "Сохранить"; + } + function formatMeetingDateTimeLine(startsAt, endsAt) { if (!isValidDate(startsAt) || !isValidDate(endsAt)) { return ""; diff --git a/frontend/src/main/resources/static/index.html b/frontend/src/main/resources/static/index.html index c53d29b..805b17c 100644 --- a/frontend/src/main/resources/static/index.html +++ b/frontend/src/main/resources/static/index.html @@ -84,7 +84,7 @@

AiCal

Цвет мероприятия - +
diff --git a/frontend/src/main/resources/static/styles.css b/frontend/src/main/resources/static/styles.css index ce28ecc..a0dec05 100644 --- a/frontend/src/main/resources/static/styles.css +++ b/frontend/src/main/resources/static/styles.css @@ -334,13 +334,9 @@ body.sidebar-open .sidebar-backdrop { line-height: 1.05; font-weight: 600; white-space: normal; + overflow-wrap: anywhere; word-break: break-word; - hyphens: auto; - display: -webkit-box; - -webkit-box-orient: vertical; - -webkit-line-clamp: 12; overflow: hidden; - text-overflow: clip; width: 100%; } From c339b23428ca5f5cd798aeec8725586d39214beb Mon Sep 17 00:00:00 2001 From: gr1shan1a <368409@edu.itmo.ru> Date: Wed, 25 Feb 2026 21:32:55 +0300 Subject: [PATCH 04/15] <25.02.2026 21:32> --- .../aichef/config/DatabaseSchemaRepair.java | 27 ++++++++++ .../controller/MiniAppMeetingController.java | 51 ++++++++++++++++--- .../aichef/repository/MeetingRepository.java | 3 ++ frontend/src/main/resources/static/app.js | 14 +++++ 4 files changed, 87 insertions(+), 8 deletions(-) create mode 100644 backend-core/src/main/java/com/aichef/config/DatabaseSchemaRepair.java diff --git a/backend-core/src/main/java/com/aichef/config/DatabaseSchemaRepair.java b/backend-core/src/main/java/com/aichef/config/DatabaseSchemaRepair.java new file mode 100644 index 0000000..77c314f --- /dev/null +++ b/backend-core/src/main/java/com/aichef/config/DatabaseSchemaRepair.java @@ -0,0 +1,27 @@ +package com.aichef.config; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import jakarta.annotation.PostConstruct; + +@Slf4j +@Component +@RequiredArgsConstructor +public class DatabaseSchemaRepair { + + private final JdbcTemplate jdbcTemplate; + + @PostConstruct + public void ensureColumns() { + try { + jdbcTemplate.execute("ALTER TABLE meetings ADD COLUMN IF NOT EXISTS color VARCHAR(7)"); + log.info("Schema repair check complete: meetings.color"); + } catch (Exception e) { + log.warn("Schema repair failed for meetings.color: {}", e.getMessage()); + } + } +} + diff --git a/backend-core/src/main/java/com/aichef/controller/MiniAppMeetingController.java b/backend-core/src/main/java/com/aichef/controller/MiniAppMeetingController.java index a0fd1d4..ca82ad8 100644 --- a/backend-core/src/main/java/com/aichef/controller/MiniAppMeetingController.java +++ b/backend-core/src/main/java/com/aichef/controller/MiniAppMeetingController.java @@ -6,6 +6,8 @@ import com.aichef.domain.model.User; import com.aichef.repository.CalendarDayRepository; import com.aichef.repository.MeetingRepository; +import com.aichef.service.GoogleCalendarService; +import com.aichef.service.GoogleOAuthService; import com.aichef.service.MiniAppAuthService; import com.aichef.util.TextNormalization; import lombok.RequiredArgsConstructor; @@ -18,6 +20,7 @@ import java.time.LocalDate; import java.time.OffsetDateTime; +import java.time.ZoneId; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -34,6 +37,8 @@ public class MiniAppMeetingController { private final MiniAppAuthService miniAppAuthService; private final MeetingRepository meetingRepository; private final CalendarDayRepository calendarDayRepository; + private final GoogleCalendarService googleCalendarService; + private final GoogleOAuthService googleOAuthService; @GetMapping public ResponseEntity list( @@ -104,6 +109,29 @@ public ResponseEntity create( user.getId(), user.getTelegramId(), request.title(), request.startsAt(), request.endsAt(), normalizedColor, e.getMessage(), e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Internal error"); } + + // Create Google event only for users with explicit OAuth connection. + if (googleOAuthService.isConnected(user)) { + GoogleCalendarService.CreatedGoogleEvent googleEvent = googleCalendarService.createEvent( + user, + meeting.getTitle(), + meeting.getStartsAt(), + meeting.getEndsAt(), + meeting.getExternalLink(), + resolveZone(user) + ); + if (googleEvent != null) { + if (googleEvent.eventId() != null && !googleEvent.eventId().isBlank()) { + meeting.setGoogleEventId(googleEvent.eventId()); + } + if ((meeting.getExternalLink() == null || meeting.getExternalLink().isBlank()) + && googleEvent.htmlLink() != null && !googleEvent.htmlLink().isBlank()) { + meeting.setExternalLink(googleEvent.htmlLink()); + } + meetingRepository.save(meeting); + } + } + log.info("MiniApp meeting created. userId={}, telegramId={}, meetingId={}, startsAt={}, endsAt={}, color={}", user.getId(), user.getTelegramId(), meeting.getId(), meeting.getStartsAt(), meeting.getEndsAt(), meeting.getColor()); return ResponseEntity.ok(MeetingDto.from(meeting)); @@ -142,10 +170,8 @@ private ResponseEntity updateInternal( return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Unauthorized"); } User user = userOpt.get(); - Meeting meeting = meetingRepository.findById(id).orElse(null); - if (meeting == null || meeting.getCalendarDay() == null - || meeting.getCalendarDay().getUser() == null - || !meeting.getCalendarDay().getUser().getId().equals(user.getId())) { + Meeting meeting = meetingRepository.findByIdAndCalendarDay_User(id, user).orElse(null); + if (meeting == null) { log.warn("MiniApp meeting update not found/forbidden. meetingId={}, userId={}, telegramId={}", id, user.getId(), user.getTelegramId()); return ResponseEntity.status(HttpStatus.NOT_FOUND).body("Meeting not found"); @@ -209,10 +235,8 @@ public ResponseEntity delete( return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Unauthorized"); } User user = userOpt.get(); - Meeting meeting = meetingRepository.findById(id).orElse(null); - if (meeting == null || meeting.getCalendarDay() == null - || meeting.getCalendarDay().getUser() == null - || !meeting.getCalendarDay().getUser().getId().equals(user.getId())) { + Meeting meeting = meetingRepository.findByIdAndCalendarDay_User(id, user).orElse(null); + if (meeting == null) { return ResponseEntity.status(HttpStatus.NOT_FOUND).body("Meeting not found"); } meeting.setStatus(MeetingStatus.CANCELED); @@ -282,6 +306,17 @@ private String normalizeHexColor(String value) { return HEX_COLOR_PATTERN.matcher(trimmed).matches() ? trimmed.toLowerCase() : null; } + private ZoneId resolveZone(User user) { + try { + if (user == null || user.getTimezone() == null || user.getTimezone().isBlank()) { + return ZoneId.of("Europe/Moscow"); + } + return ZoneId.of(user.getTimezone()); + } catch (Exception ignored) { + return ZoneId.of("Europe/Moscow"); + } + } + @ExceptionHandler(MethodArgumentTypeMismatchException.class) public ResponseEntity handleTypeMismatch(MethodArgumentTypeMismatchException e) { log.error("MiniApp meeting request type mismatch. message={}", e.getMessage(), e); diff --git a/backend-core/src/main/java/com/aichef/repository/MeetingRepository.java b/backend-core/src/main/java/com/aichef/repository/MeetingRepository.java index 6535704..e764ea8 100644 --- a/backend-core/src/main/java/com/aichef/repository/MeetingRepository.java +++ b/backend-core/src/main/java/com/aichef/repository/MeetingRepository.java @@ -6,10 +6,13 @@ import java.time.LocalDate; import java.util.List; +import java.util.Optional; import java.util.UUID; public interface MeetingRepository extends JpaRepository { List findByCalendarDay_UserAndCalendarDay_DayDateBetweenOrderByStartsAtAsc(User user, LocalDate from, LocalDate to); List findByCalendarDay_UserOrderByStartsAtAsc(User user); + + Optional findByIdAndCalendarDay_User(UUID id, User user); } diff --git a/frontend/src/main/resources/static/app.js b/frontend/src/main/resources/static/app.js index 16a4fb6..c8bb17e 100644 --- a/frontend/src/main/resources/static/app.js +++ b/frontend/src/main/resources/static/app.js @@ -128,6 +128,12 @@ el.eventEditBtn.addEventListener("click", () => { const nextMode = !state.editMode; setEditMode(nextMode); + if (nextMode && el.eventEditTitle) { + window.setTimeout(() => { + el.eventEditTitle.focus(); + el.eventEditTitle.select(); + }, 0); + } }); } if (el.eventEditForm) { @@ -530,6 +536,14 @@ state.formMode = "duplicate"; setEditMode(true); + if (el.eventEditTitle) { + const originalTitle = String(activeMeeting.title || "Событие").trim(); + el.eventEditTitle.value = `Копия: ${originalTitle}`; + window.setTimeout(() => { + el.eventEditTitle.focus(); + el.eventEditTitle.select(); + }, 0); + } setEventModalStatus("Режим дублирования: укажите новое название, дату и время, затем сохраните."); } From d63e21d6dd470592540ee32221d3c5227ac81201 Mon Sep 17 00:00:00 2001 From: gr1shan1a <368409@edu.itmo.ru> Date: Wed, 25 Feb 2026 23:59:15 +0300 Subject: [PATCH 05/15] <25.02.2026 23:58> --- .../service/TelegramPollingService.java | 33 ++++++++++++------- frontend/src/main/resources/static/app.js | 11 ++++--- frontend/src/main/resources/static/common.js | 11 ++++--- 3 files changed, 36 insertions(+), 19 deletions(-) diff --git a/backend-core/src/main/java/com/aichef/service/TelegramPollingService.java b/backend-core/src/main/java/com/aichef/service/TelegramPollingService.java index f49579a..d78c5b9 100644 --- a/backend-core/src/main/java/com/aichef/service/TelegramPollingService.java +++ b/backend-core/src/main/java/com/aichef/service/TelegramPollingService.java @@ -13,7 +13,6 @@ import org.springframework.web.client.RestClient; import org.springframework.web.client.RestClientException; -import java.net.URI; import java.util.List; import java.util.Map; import java.util.concurrent.Executor; @@ -48,10 +47,13 @@ public TelegramPollingService(TelegramProperties properties, private final AtomicBoolean pollingConflictLogged = new AtomicBoolean(false); private final AtomicInteger networkErrorStreak = new AtomicInteger(0); private final AtomicLong nextPollAllowedAtMs = new AtomicLong(0); + private final AtomicLong webhookStateCheckedAtMs = new AtomicLong(0); + private final AtomicBoolean webhookActive = new AtomicBoolean(false); + private static final long WEBHOOK_STATE_CACHE_MS = 30_000; @Scheduled(fixedDelayString = "${app.telegram.poll-interval-ms:3000}") public void pollUpdates() { - if (isWebhookModeEnabled(properties.publicBaseUrl())) { + if (isWebhookCurrentlyActive()) { return; } if (System.currentTimeMillis() < nextPollAllowedAtMs.get()) { @@ -151,19 +153,28 @@ private String summarizeCause(Throwable cause) { return cause.getClass().getSimpleName() + ": " + message; } - private boolean isWebhookModeEnabled(String baseUrl) { - if (baseUrl == null || baseUrl.isBlank()) { - return false; + private boolean isWebhookCurrentlyActive() { + long now = System.currentTimeMillis(); + if (now - webhookStateCheckedAtMs.get() < WEBHOOK_STATE_CACHE_MS) { + return webhookActive.get(); } + + boolean active = false; try { - URI uri = URI.create(baseUrl.trim()); - String host = uri.getHost(); - if (host == null) { - return false; + Map response = telegramRestClient.get() + .uri("/bot{token}/getWebhookInfo", properties.botToken()) + .retrieve() + .body(Map.class); + if (response != null && Boolean.TRUE.equals(response.get("ok")) && response.get("result") instanceof Map result) { + Object urlObj = result.get("url"); + String url = urlObj instanceof String s ? s.trim() : ""; + active = !url.isBlank(); } - return !("localhost".equalsIgnoreCase(host) || "127.0.0.1".equals(host) || "::1".equals(host)); } catch (Exception e) { - return false; + log.warn("Failed to check webhook state, assuming polling mode. error={}", e.getMessage()); } + webhookActive.set(active); + webhookStateCheckedAtMs.set(now); + return active; } } diff --git a/frontend/src/main/resources/static/app.js b/frontend/src/main/resources/static/app.js index c8bb17e..38008a8 100644 --- a/frontend/src/main/resources/static/app.js +++ b/frontend/src/main/resources/static/app.js @@ -711,13 +711,13 @@ const host = window.location.hostname || ""; const base = getApiBaseUrl(); const explicitBase = readQueryApiBase() || readConfigApiBase() || readSavedApiBase(); + const inferredRenderBase = inferRenderMiniAppApiBase(); const list = [base]; + if (inferredRenderBase && inferredRenderBase !== base) { + list.push(inferredRenderBase); + } if (!explicitBase) { - const inferredRenderBase = inferRenderMiniAppApiBase(); - if (inferredRenderBase) { - list.push(inferredRenderBase); - } if (host) { list.push(`${protocol}//${host}:8011`); list.push(`${protocol}//${host}:8010`); @@ -736,6 +736,9 @@ if (origin && origin !== base) { list.push(origin); } + if (inferredRenderBase && inferredRenderBase !== origin) { + list.push(inferredRenderBase); + } } return Array.from(new Set(list.map((x) => String(x || "").trim().replace(/\/+$/, "")).filter(Boolean))); diff --git a/frontend/src/main/resources/static/common.js b/frontend/src/main/resources/static/common.js index fa329bb..2596d25 100644 --- a/frontend/src/main/resources/static/common.js +++ b/frontend/src/main/resources/static/common.js @@ -200,13 +200,13 @@ const host = window.location.hostname || ""; const base = getApiBaseUrl(); const explicitBase = readQueryApiBase() || readConfigApiBase() || readSavedApiBase(); + const inferredRenderBase = inferRenderMiniAppApiBase(); const list = [base]; + if (inferredRenderBase && inferredRenderBase !== base) { + list.push(inferredRenderBase); + } if (!explicitBase) { - const inferredRenderBase = inferRenderMiniAppApiBase(); - if (inferredRenderBase) { - list.push(inferredRenderBase); - } if (host) { list.push(`${protocol}//${host}:8011`); list.push(`${protocol}//${host}:8010`); @@ -225,6 +225,9 @@ if (origin && origin !== base) { list.push(origin); } + if (inferredRenderBase && inferredRenderBase !== origin) { + list.push(inferredRenderBase); + } } return Array.from(new Set(list.map((x) => String(x || "").trim().replace(/\/+$/, "")).filter(Boolean))); From 7e87b01a6cea28d0bc490491a6551472c2dfc596 Mon Sep 17 00:00:00 2001 From: gr1shan1a <368409@edu.itmo.ru> Date: Thu, 26 Feb 2026 00:16:36 +0300 Subject: [PATCH 06/15] <26.02.2026 00:16> --- .../java/com/aichef/config/TelegramProperties.java | 3 ++- .../com/aichef/config/TelegramWebhookRegistrar.java | 12 ++++++++++-- .../com/aichef/service/TelegramPollingService.java | 13 ++++++++++++- miniapp-backend/src/main/resources/application.yml | 1 + render.yaml | 2 ++ telegram-backend/src/main/resources/application.yml | 1 + 6 files changed, 28 insertions(+), 4 deletions(-) diff --git a/backend-core/src/main/java/com/aichef/config/TelegramProperties.java b/backend-core/src/main/java/com/aichef/config/TelegramProperties.java index 1360825..b31d5c7 100644 --- a/backend-core/src/main/java/com/aichef/config/TelegramProperties.java +++ b/backend-core/src/main/java/com/aichef/config/TelegramProperties.java @@ -12,6 +12,7 @@ public record TelegramProperties( @NotBlank String webhookSecret, @NotBlank String webhookPath, @NotBlank String apiBase, - String publicBaseUrl + String publicBaseUrl, + boolean useWebhook ) { } diff --git a/backend-core/src/main/java/com/aichef/config/TelegramWebhookRegistrar.java b/backend-core/src/main/java/com/aichef/config/TelegramWebhookRegistrar.java index 60723b3..047720a 100644 --- a/backend-core/src/main/java/com/aichef/config/TelegramWebhookRegistrar.java +++ b/backend-core/src/main/java/com/aichef/config/TelegramWebhookRegistrar.java @@ -20,13 +20,21 @@ public class TelegramWebhookRegistrar implements ApplicationRunner { @Override public void run(ApplicationArguments args) { String baseUrl = properties.publicBaseUrl(); - log.info("Telegram config: botUsername={}, webhookPath={}, hasPublicBaseUrl={}, hasToken={}", + log.info("Telegram config: botUsername={}, webhookPath={}, hasPublicBaseUrl={}, hasToken={}, useWebhook={}", properties.botUsername(), properties.webhookPath(), baseUrl != null && !baseUrl.isBlank(), - properties.botToken() != null && !properties.botToken().isBlank()); + properties.botToken() != null && !properties.botToken().isBlank(), + properties.useWebhook()); telegramBotService.configureMiniAppEntryPoints(); + if (!properties.useWebhook()) { + log.info("Webhook is disabled by TELEGRAM_USE_WEBHOOK=false. Deleting webhook and enabling polling."); + telegramBotService.deleteWebhook(false); + telegramBotService.logWebhookInfo(); + return; + } + String webhookBaseUrl = resolveWebhookBaseUrl(baseUrl); if (webhookBaseUrl == null) { log.info("Webhook is disabled for current APP_PUBLIC_BASE_URL. Local mode: deleting webhook and enabling polling."); diff --git a/backend-core/src/main/java/com/aichef/service/TelegramPollingService.java b/backend-core/src/main/java/com/aichef/service/TelegramPollingService.java index d78c5b9..3e67735 100644 --- a/backend-core/src/main/java/com/aichef/service/TelegramPollingService.java +++ b/backend-core/src/main/java/com/aichef/service/TelegramPollingService.java @@ -53,7 +53,7 @@ public TelegramPollingService(TelegramProperties properties, @Scheduled(fixedDelayString = "${app.telegram.poll-interval-ms:3000}") public void pollUpdates() { - if (isWebhookCurrentlyActive()) { + if (properties.useWebhook() && isWebhookCurrentlyActive()) { return; } if (System.currentTimeMillis() < nextPollAllowedAtMs.get()) { @@ -95,6 +95,12 @@ public void pollUpdates() { } } catch (HttpClientErrorException.Conflict e) { String msg = e.getMessage() == null ? "" : e.getMessage(); + if (msg.contains("can't use getUpdates method while webhook is active")) { + log.warn("Polling conflict with active webhook detected. Deleting webhook and continuing in polling mode."); + telegramBotService.deleteWebhook(false); + resetWebhookStateCache(); + return; + } if (msg.contains("terminated by other getUpdates request")) { if (pollingConflictLogged.compareAndSet(false, true)) { log.error("Polling conflict: another bot instance is running. Stop extra instance to continue polling."); @@ -177,4 +183,9 @@ private boolean isWebhookCurrentlyActive() { webhookStateCheckedAtMs.set(now); return active; } + + private void resetWebhookStateCache() { + webhookActive.set(false); + webhookStateCheckedAtMs.set(0); + } } diff --git a/miniapp-backend/src/main/resources/application.yml b/miniapp-backend/src/main/resources/application.yml index 1bf34ad..8fea6a3 100644 --- a/miniapp-backend/src/main/resources/application.yml +++ b/miniapp-backend/src/main/resources/application.yml @@ -50,6 +50,7 @@ app: webhook-path: ${TELEGRAM_WEBHOOK_PATH:/api/telegram/webhook} api-base: ${TELEGRAM_API_BASE:https://api.telegram.org} public-base-url: ${APP_PUBLIC_BASE_URL:http://localhost:8010} + use-webhook: ${TELEGRAM_USE_WEBHOOK:false} poll-interval-ms: ${TELEGRAM_POLL_INTERVAL_MS:3000} processing-threads: ${TELEGRAM_PROCESSING_THREADS:2} processing-queue-capacity: ${TELEGRAM_PROCESSING_QUEUE_CAPACITY:200} diff --git a/render.yaml b/render.yaml index ed9153e..d7960d7 100644 --- a/render.yaml +++ b/render.yaml @@ -81,6 +81,8 @@ services: sync: false - key: TELEGRAM_WEBHOOK_PATH value: /api/telegram/webhook + - key: TELEGRAM_USE_WEBHOOK + value: "false" - key: TELEGRAM_API_BASE value: https://api.telegram.org - key: APP_PUBLIC_BASE_URL diff --git a/telegram-backend/src/main/resources/application.yml b/telegram-backend/src/main/resources/application.yml index c54c685..261484b 100644 --- a/telegram-backend/src/main/resources/application.yml +++ b/telegram-backend/src/main/resources/application.yml @@ -50,6 +50,7 @@ app: webhook-path: ${TELEGRAM_WEBHOOK_PATH:/api/telegram/webhook} api-base: ${TELEGRAM_API_BASE:https://api.telegram.org} public-base-url: ${APP_PUBLIC_BASE_URL:} + use-webhook: ${TELEGRAM_USE_WEBHOOK:false} poll-interval-ms: ${TELEGRAM_POLL_INTERVAL_MS:3000} processing-threads: ${TELEGRAM_PROCESSING_THREADS:4} processing-queue-capacity: ${TELEGRAM_PROCESSING_QUEUE_CAPACITY:500} From 34b0b1309ae98ea7a0e892c8f2aecd756fe691bb Mon Sep 17 00:00:00 2001 From: gr1shan1a <368409@edu.itmo.ru> Date: Thu, 26 Feb 2026 10:56:45 +0300 Subject: [PATCH 07/15] <26.02.2026 10:56> --- frontend/src/main/resources/static/app.js | 76 +++++++++++++++++++- frontend/src/main/resources/static/common.js | 70 +++++++++++++++++- frontend/src/main/resources/static/notes.js | 14 +++- frontend/src/main/resources/static/tasks.js | 14 +++- 4 files changed, 164 insertions(+), 10 deletions(-) diff --git a/frontend/src/main/resources/static/app.js b/frontend/src/main/resources/static/app.js index 38008a8..e3fbe5b 100644 --- a/frontend/src/main/resources/static/app.js +++ b/frontend/src/main/resources/static/app.js @@ -10,6 +10,8 @@ const API_REQUEST_TIMEOUT_MS = 7000; const PROFILE_REQUEST_TIMEOUT_MS = 1500; const TELEGRAM_ID_STORAGE_KEY = "aical_telegram_id"; + const WAKEUP_TIMEOUT_MS = 45000; + const WAKEUP_STEP_MS = 2500; const page = document.body.dataset.page || "schedule"; @@ -51,7 +53,8 @@ nowTimer: null, activeMeetingId: "", editMode: false, - formMode: "edit" + formMode: "edit", + wakeUpDone: false }; init(); @@ -264,7 +267,11 @@ } async function loadMeetingsAndRender() { - setScheduleStatus("Загрузка..."); + if (!state.wakeUpDone) { + await warmUpBackends(); + state.wakeUpDone = true; + } + setScheduleStatus("Подготавливаю события и синхронизирую календарь..."); const from = toYmd(state.weekStart); const to = toYmd(addDays(state.weekStart, 6)); const endpoints = getMeetingEndpoints().map((p) => `${p}?from=${from}&to=${to}`); @@ -689,7 +696,7 @@ function apiErrorMessage(status) { if (status === 401) return "Нет доступа. Открой Mini App через Telegram"; - if (status === 404) return "API не найден. Проверь apiBaseUrl"; + if (status === 404) return "Календарный сервис поднимается. Данные скоро появятся."; return `Ошибка API (${status})`; } @@ -812,6 +819,69 @@ } } + async function warmUpBackends() { + const startedAt = Date.now(); + const bases = getApiBaseCandidates(); + const extras = getSiblingServiceOrigins(); + for (const origin of extras) { + fireWakePing(origin); + } + while (Date.now() - startedAt < WAKEUP_TIMEOUT_MS) { + for (const base of bases) { + const ok = await probeApiBase(base); + if (ok) { + setScheduleStatus("Данные готовы, отображаю события..."); + return; + } + } + const sec = Math.max(1, Math.round((Date.now() - startedAt) / 1000)); + setScheduleStatus(`Подключаю сервисы и собираю ваши данные (${sec}s)...`); + await sleep(WAKEUP_STEP_MS); + } + } + + async function probeApiBase(base) { + const auth = buildAuth(); + const url = new URL(base + "/api/miniapp/me"); + if (auth.telegramId) { + url.searchParams.set("telegramId", auth.telegramId); + } + try { + const response = await fetchWithTimeout(url.toString(), { headers: auth.headers }, 3500); + return response.ok || response.status === 401 || response.status === 403 || response.status === 400; + } catch { + return false; + } + } + + function getSiblingServiceOrigins() { + const protocol = window.location.protocol || "https:"; + const host = window.location.hostname || ""; + if (!host.endsWith(".onrender.com")) return []; + const set = new Set(); + if (host.includes("-frontend.")) { + set.add(`${protocol}//${host.replace("-frontend.", "-miniapp-api.")}`); + set.add(`${protocol}//${host.replace("-frontend.", "-telegram.")}`); + } else if (host.includes("frontend")) { + set.add(`${protocol}//${host.replace("frontend", "miniapp-api")}`); + set.add(`${protocol}//${host.replace("frontend", "telegram")}`); + } + return Array.from(set); + } + + function fireWakePing(origin) { + if (!origin) return; + try { + fetch(origin.replace(/\/+$/, "") + "/actuator/health", { mode: "no-cors", cache: "no-store" }).catch(() => {}); + } catch { + // ignore + } + } + + function sleep(ms) { + return new Promise((resolve) => window.setTimeout(resolve, ms)); + } + function setScheduleStatus(message) { if (el.scheduleStatus) el.scheduleStatus.textContent = message || ""; } diff --git a/frontend/src/main/resources/static/common.js b/frontend/src/main/resources/static/common.js index 2596d25..6c594c3 100644 --- a/frontend/src/main/resources/static/common.js +++ b/frontend/src/main/resources/static/common.js @@ -8,6 +8,8 @@ const page = document.body.dataset.page || "schedule"; const PROFILE_REQUEST_TIMEOUT_MS = 1500; const TELEGRAM_ID_STORAGE_KEY = "aical_telegram_id"; + const WAKEUP_TIMEOUT_MS = 45000; + const WAKEUP_STEP_MS = 2500; const el = { menu: document.getElementById("menu"), @@ -155,7 +157,8 @@ getApiBaseUrl, getApiBaseCandidates, buildAuth, - getEndpointCandidates + getEndpointCandidates, + wakeUpServices }; function getApiBaseUrl() { @@ -289,6 +292,71 @@ return fallback; } + async function wakeUpServices(statusCallback) { + const startedAt = Date.now(); + const bases = getApiBaseCandidates(); + const extras = getSiblingServiceOrigins(); + for (const origin of extras) { + fireWakePing(origin); + } + + while (Date.now() - startedAt < WAKEUP_TIMEOUT_MS) { + for (const base of bases) { + const ok = await probeApiBase(base); + if (ok) { + statusCallback && statusCallback("Данные готовы, отображаю обновления..."); + return true; + } + } + const sec = Math.max(1, Math.round((Date.now() - startedAt) / 1000)); + statusCallback && statusCallback(`Подключаю сервисы и собираю ваши данные (${sec}s)...`); + await sleep(WAKEUP_STEP_MS); + } + return false; + } + + async function probeApiBase(base) { + const auth = buildAuth(); + const url = new URL(base + "/api/miniapp/me"); + if (auth.telegramId) { + url.searchParams.set("telegramId", auth.telegramId); + } + try { + const response = await fetchWithTimeout(url.toString(), { headers: auth.headers }, 3500); + return response.ok || response.status === 401 || response.status === 403 || response.status === 400; + } catch { + return false; + } + } + + function getSiblingServiceOrigins() { + const protocol = window.location.protocol || "https:"; + const host = window.location.hostname || ""; + if (!host.endsWith(".onrender.com")) return []; + const set = new Set(); + if (host.includes("-frontend.")) { + set.add(`${protocol}//${host.replace("-frontend.", "-miniapp-api.")}`); + set.add(`${protocol}//${host.replace("-frontend.", "-telegram.")}`); + } else if (host.includes("frontend")) { + set.add(`${protocol}//${host.replace("frontend", "miniapp-api")}`); + set.add(`${protocol}//${host.replace("frontend", "telegram")}`); + } + return Array.from(set); + } + + function fireWakePing(origin) { + if (!origin) return; + try { + fetch(origin.replace(/\/+$/, "") + "/actuator/health", { mode: "no-cors", cache: "no-store" }).catch(() => {}); + } catch { + // ignore + } + } + + function sleep(ms) { + return new Promise((resolve) => window.setTimeout(resolve, ms)); + } + async function fetchWithTimeout(resource, init, timeoutMs) { const controller = new AbortController(); const timer = window.setTimeout(() => controller.abort(), timeoutMs); diff --git a/frontend/src/main/resources/static/notes.js b/frontend/src/main/resources/static/notes.js index fd962cc..e96bdcf 100644 --- a/frontend/src/main/resources/static/notes.js +++ b/frontend/src/main/resources/static/notes.js @@ -14,7 +14,15 @@ function init() { if (!el.form || !el.list) return; el.form.addEventListener("submit", onCreate); - loadNotes(); + bootAndLoadNotes(); + } + + async function bootAndLoadNotes() { + const common = window.AiCalCommon; + if (common && typeof common.wakeUpServices === "function") { + await common.wakeUpServices((msg) => setStatus(msg)); + } + await loadNotes(); } async function onCreate(e) { @@ -43,7 +51,7 @@ } async function loadNotes() { - setStatus("Загрузка..."); + setStatus("Подготавливаю заметки и подтягиваю последние записи..."); const res = await requestWithFallback(getNoteEndpoints(), [{ method: "GET" }]); if (!res.success) { setStatus(res.message); @@ -141,7 +149,7 @@ function apiErrorMessage(status) { if (status === 401) return "Нет доступа. Открой Mini App через Telegram"; - if (status === 404) return "API не найден. Проверь apiBaseUrl"; + if (status === 404) return "Сервис заметок прогревается. Попробуйте через несколько секунд."; return `Ошибка API (${status})`; } diff --git a/frontend/src/main/resources/static/tasks.js b/frontend/src/main/resources/static/tasks.js index 2ba185d..ab40448 100644 --- a/frontend/src/main/resources/static/tasks.js +++ b/frontend/src/main/resources/static/tasks.js @@ -38,7 +38,15 @@ el.form.addEventListener("submit", onCreate); } - loadTasks(); + bootAndLoadTasks(); + } + + async function bootAndLoadTasks() { + const common = window.AiCalCommon; + if (common && typeof common.wakeUpServices === "function") { + await common.wakeUpServices((msg) => setStatus(msg)); + } + await loadTasks(); } async function onCreate(e) { @@ -73,7 +81,7 @@ } async function loadTasks() { - setStatus("Загрузка..."); + setStatus("Подготавливаю задачи и синхронизирую изменения..."); const res = await requestWithFallback(getTaskEndpoints(), [{ method: "GET" }]); if (!res.success) { renderTasks([]); @@ -232,7 +240,7 @@ function apiErrorMessage(status) { if (status === 401) return "Нет доступа. Открой Mini App через Telegram"; - if (status === 404) return "API не найден. Проверь apiBaseUrl"; + if (status === 404) return "Сервис задач поднимается. Данные скоро появятся."; return `Ошибка API (${status})`; } From 10cae8a1025690dc5754cfbce13be58072e56e70 Mon Sep 17 00:00:00 2001 From: gr1shan1a <368409@edu.itmo.ru> Date: Thu, 26 Feb 2026 11:20:07 +0300 Subject: [PATCH 08/15] <26.02.2026 10:56> --- .../controller/MiniAppMeetingController.java | 47 ++++++++ .../aichef/service/GoogleCalendarService.java | 107 ++++++++++++++++++ .../service/MessageUnderstandingService.java | 2 +- .../aichef/service/TelegramBotService.java | 15 ++- frontend/src/main/resources/static/notes.html | 24 +++- frontend/src/main/resources/static/notes.js | 41 ++++++- 6 files changed, 224 insertions(+), 12 deletions(-) diff --git a/backend-core/src/main/java/com/aichef/controller/MiniAppMeetingController.java b/backend-core/src/main/java/com/aichef/controller/MiniAppMeetingController.java index ca82ad8..17a63b6 100644 --- a/backend-core/src/main/java/com/aichef/controller/MiniAppMeetingController.java +++ b/backend-core/src/main/java/com/aichef/controller/MiniAppMeetingController.java @@ -219,6 +219,53 @@ private ResponseEntity updateInternal( user.getId(), user.getTelegramId(), meeting.getId(), meeting.getStartsAt(), meeting.getEndsAt(), meeting.getColor(), e.getMessage(), e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Internal error"); } + + if (googleOAuthService.isConnected(user)) { + ZoneId zone = resolveZone(user); + GoogleCalendarService.CreatedGoogleEvent googleEvent = null; + String googleEventId = meeting.getGoogleEventId(); + if (googleEventId != null && !googleEventId.isBlank()) { + googleEvent = googleCalendarService.updateEvent( + user, + googleEventId, + meeting.getTitle(), + meeting.getStartsAt(), + meeting.getEndsAt(), + meeting.getExternalLink(), + zone + ); + } + if (googleEvent == null) { + googleEvent = googleCalendarService.createEvent( + user, + meeting.getTitle(), + meeting.getStartsAt(), + meeting.getEndsAt(), + meeting.getExternalLink(), + zone + ); + } + if (googleEvent != null) { + boolean changed = false; + if (googleEvent.eventId() != null && !googleEvent.eventId().isBlank() + && (meeting.getGoogleEventId() == null || !meeting.getGoogleEventId().equals(googleEvent.eventId()))) { + meeting.setGoogleEventId(googleEvent.eventId()); + changed = true; + } + if (googleEvent.htmlLink() != null && !googleEvent.htmlLink().isBlank() + && (meeting.getExternalLink() == null || meeting.getExternalLink().isBlank())) { + meeting.setExternalLink(googleEvent.htmlLink()); + changed = true; + } + if (changed) { + meetingRepository.save(meeting); + } + } else { + log.warn("MiniApp meeting update synced only in DB. Google sync failed. userId={}, telegramId={}, meetingId={}, googleEventId={}", + user.getId(), user.getTelegramId(), meeting.getId(), meeting.getGoogleEventId()); + } + } + log.info("MiniApp meeting updated. userId={}, telegramId={}, meetingId={}, startsAt={}, endsAt={}, color={}", user.getId(), user.getTelegramId(), meeting.getId(), meeting.getStartsAt(), meeting.getEndsAt(), meeting.getColor()); return ResponseEntity.ok(MeetingDto.from(meeting)); diff --git a/backend-core/src/main/java/com/aichef/service/GoogleCalendarService.java b/backend-core/src/main/java/com/aichef/service/GoogleCalendarService.java index facabac..aee0391 100644 --- a/backend-core/src/main/java/com/aichef/service/GoogleCalendarService.java +++ b/backend-core/src/main/java/com/aichef/service/GoogleCalendarService.java @@ -227,6 +227,113 @@ public CreatedGoogleEvent createEvent(User user, String title, OffsetDateTime st } } + public CreatedGoogleEvent updateEvent( + User user, + String eventId, + String title, + OffsetDateTime startsAt, + OffsetDateTime endsAt, + String externalLink, + ZoneId zoneId + ) { + if (eventId == null || eventId.isBlank()) { + return null; + } + String calendarId = resolveCalendarId(user); + String accessToken = resolveAccessToken(user); + if (calendarId == null || accessToken == null) { + log.warn( + "Skip Google Calendar update: missing calendarId or accessToken. userId={}, hasCalendarId={}, hasAccessToken={}, eventId={}", + user == null ? null : user.getId(), + calendarId != null && !calendarId.isBlank(), + accessToken != null && !accessToken.isBlank(), + eventId + ); + return null; + } + + try { + RestClient client = RestClient.builder().baseUrl(properties.safeApiBase()).build(); + log.info( + "Google Calendar update requested. userId={}, calendarId={}, eventId={}, title={}, startsAt={}, endsAt={}, zone={}", + user == null ? null : user.getId(), + calendarId, + eventId, + title, + startsAt, + endsAt, + zoneId == null ? null : zoneId.getId() + ); + + Map payload = new HashMap<>(); + payload.put("summary", title); + payload.put("description", externalLink); + payload.put("start", Map.of( + "dateTime", startsAt.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME), + "timeZone", zoneId.getId() + )); + payload.put("end", Map.of( + "dateTime", endsAt.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME), + "timeZone", zoneId.getId() + )); + + Map response = client.patch() + .uri("/calendars/{calendarId}/events/{eventId}", calendarId, eventId) + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .body(payload) + .retrieve() + .body(Map.class); + + if (response == null) { + log.warn( + "Google Calendar update returned empty response. userId={}, calendarId={}, eventId={}", + user == null ? null : user.getId(), + calendarId, + eventId + ); + return new CreatedGoogleEvent(eventId, null); + } + Object responseEventId = response.get("id"); + Object htmlLink = response.get("htmlLink"); + String updatedId = responseEventId instanceof String s ? s : eventId; + String link = htmlLink instanceof String s ? s : null; + log.info( + "Google Calendar update succeeded. userId={}, calendarId={}, eventId={}, htmlLink={}", + user == null ? null : user.getId(), + calendarId, + updatedId, + link + ); + return new CreatedGoogleEvent(updatedId, link); + } catch (RestClientResponseException e) { + log.error( + "Google Calendar update failed. status={}, userId={}, calendarId={}, eventId={}, title={}, startsAt={}, endsAt={}, body={}", + e.getStatusCode(), + user == null ? null : user.getId(), + calendarId, + eventId, + title, + startsAt, + endsAt, + e.getResponseBodyAsString() + ); + return null; + } catch (Exception e) { + log.error( + "Failed to update Google Calendar event. userId={}, calendarId={}, eventId={}, title={}, startsAt={}, endsAt={}, error={}", + user == null ? null : user.getId(), + calendarId, + eventId, + title, + startsAt, + endsAt, + e.getMessage() + ); + return null; + } + } + public record CreatedGoogleEvent(String eventId, String htmlLink) { } diff --git a/backend-core/src/main/java/com/aichef/service/MessageUnderstandingService.java b/backend-core/src/main/java/com/aichef/service/MessageUnderstandingService.java index 413461a..91e59f0 100644 --- a/backend-core/src/main/java/com/aichef/service/MessageUnderstandingService.java +++ b/backend-core/src/main/java/com/aichef/service/MessageUnderstandingService.java @@ -243,7 +243,7 @@ public MessageIntent decide(String sourceText, ZoneId zoneId) { null, null, null, - text, + null, link, "📝 Сохранил как заметку." ); diff --git a/backend-core/src/main/java/com/aichef/service/TelegramBotService.java b/backend-core/src/main/java/com/aichef/service/TelegramBotService.java index e11d826..4fd83bf 100644 --- a/backend-core/src/main/java/com/aichef/service/TelegramBotService.java +++ b/backend-core/src/main/java/com/aichef/service/TelegramBotService.java @@ -626,8 +626,19 @@ private String applyIntent(User user, InboundItem inboundItem, MessageIntent int if (intent.action() == BotAction.CREATE_NOTE) { Note note = new Note(); note.setUser(user); - note.setTitle(TextNormalization.normalizeRussian(intent.title() == null ? "Заметка" : intent.title())); - note.setContent(TextNormalization.normalizeRussian(intent.noteContent() == null ? "" : intent.noteContent())); + String rawTitle = intent.title() == null ? "Заметка" : intent.title(); + String normalizedTitle = TextNormalization.normalizeRussian(rawTitle); + String rawContent = intent.noteContent() == null ? "" : intent.noteContent(); + String normalizedContent = TextNormalization.normalizeRussian(rawContent); + + note.setTitle(normalizedTitle); + if (normalizedContent != null + && !normalizedContent.isBlank() + && !normalizedContent.trim().equalsIgnoreCase((normalizedTitle == null ? "" : normalizedTitle).trim())) { + note.setContent(normalizedContent); + } else { + note.setContent(""); + } noteRepository.save(note); return "📝 Заметка сохранена.\nID: " + note.getId(); } diff --git a/frontend/src/main/resources/static/notes.html b/frontend/src/main/resources/static/notes.html index 74e5de2..66f0d4e 100644 --- a/frontend/src/main/resources/static/notes.html +++ b/frontend/src/main/resources/static/notes.html @@ -33,18 +33,30 @@

AiCal

-
Заметки
-
- - - -
+
+
Заметки
+ +
+ + diff --git a/frontend/src/main/resources/static/notes.js b/frontend/src/main/resources/static/notes.js index e96bdcf..7de9e02 100644 --- a/frontend/src/main/resources/static/notes.js +++ b/frontend/src/main/resources/static/notes.js @@ -2,6 +2,9 @@ const API_REQUEST_TIMEOUT_MS = 7000; const el = { + addBtn: document.getElementById("noteAddBtn"), + modal: document.getElementById("noteModal"), + modalClose: document.getElementById("noteModalClose"), form: document.getElementById("noteForm"), title: document.getElementById("noteTitle"), content: document.getElementById("noteContent"), @@ -12,8 +15,26 @@ init(); function init() { - if (!el.form || !el.list) return; - el.form.addEventListener("submit", onCreate); + if (!el.list) return; + + if (el.addBtn) { + el.addBtn.addEventListener("click", openModal); + } + + if (el.modalClose) { + el.modalClose.addEventListener("click", closeModal); + } + + if (el.modal) { + el.modal.addEventListener("click", (e) => { + if (e.target === el.modal) closeModal(); + }); + } + + if (el.form) { + el.form.addEventListener("submit", onCreate); + } + bootAndLoadNotes(); } @@ -29,7 +50,7 @@ e.preventDefault(); const title = (el.title.value || "").trim(); const content = (el.content.value || "").trim(); - if (!title || !content) return; + if (!title) return; setStatus("Сохраняю..."); const ok = await requestWithFallback( @@ -46,6 +67,7 @@ } el.form.reset(); + closeModal(); setStatus("Заметка создана"); await loadNotes(); } @@ -77,6 +99,19 @@ } } + function openModal() { + if (!el.modal) return; + el.modal.classList.remove("hidden"); + if (el.title) { + window.setTimeout(() => el.title.focus(), 0); + } + } + + function closeModal() { + if (!el.modal) return; + el.modal.classList.add("hidden"); + } + async function request(path, init = {}, forcedBase = "") { const common = window.AiCalCommon; const base = forcedBase || (common && common.getApiBaseUrl ? common.getApiBaseUrl() : window.location.origin); From 33e3a5762048190dc9d26c4deafe40fae97a57c5 Mon Sep 17 00:00:00 2001 From: gr1shan1a <368409@edu.itmo.ru> Date: Thu, 26 Feb 2026 11:43:23 +0300 Subject: [PATCH 09/15] <26.02.2026 11:43> --- frontend/src/main/resources/static/tasks.js | 48 +++++++++++++++++++-- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/frontend/src/main/resources/static/tasks.js b/frontend/src/main/resources/static/tasks.js index ab40448..dbb7e97 100644 --- a/frontend/src/main/resources/static/tasks.js +++ b/frontend/src/main/resources/static/tasks.js @@ -1,5 +1,6 @@ (() => { const API_REQUEST_TIMEOUT_MS = 7000; + const DELETE_DELAY_SECONDS = 5; const el = { addBtn: document.getElementById("taskAddBtn"), @@ -12,6 +13,7 @@ list: document.getElementById("taskList"), status: document.getElementById("taskStatus") }; + const pendingDelete = new Map(); init(); @@ -114,6 +116,36 @@ `; check.addEventListener("click", async () => { + const pending = pendingDelete.get(task.id); + if (pending) { + window.clearTimeout(pending.timeoutId); + window.clearInterval(pending.intervalId); + pendingDelete.delete(task.id); + + check.disabled = false; + check.classList.remove("done"); + text.classList.remove("done"); + setStatus("Отменяю удаление..."); + + const rollbackRes = await requestWithFallback( + getTaskEndpoints().map((base) => `${base}/${task.id}`), + [ + { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ completed: false }) }, + { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ completed: false }) } + ] + ); + + if (!rollbackRes.success) { + check.classList.add("done"); + text.classList.add("done"); + setStatus(rollbackRes.message); + return; + } + + setStatus("Удаление отменено"); + return; + } + check.disabled = true; check.classList.add("done"); text.classList.add("done"); @@ -134,8 +166,17 @@ return; } - setStatus("Задача выполнена. Удалю через 5 секунд..."); - window.setTimeout(async () => { + let remaining = DELETE_DELAY_SECONDS; + setStatus(`Задача выполнена. Удалю через ${remaining}... Нажмите еще раз, чтобы отменить.`); + const intervalId = window.setInterval(() => { + remaining -= 1; + if (remaining > 0) { + setStatus(`Задача выполнена. Удалю через ${remaining}... Нажмите еще раз, чтобы отменить.`); + } + }, 1000); + const timeoutId = window.setTimeout(async () => { + window.clearInterval(intervalId); + pendingDelete.delete(task.id); await requestWithFallback( getTaskEndpoints().map((base) => `${base}/${task.id}`), [{ method: "DELETE" }] @@ -146,7 +187,8 @@ } else { setStatus(""); } - }, 5000); + }, DELETE_DELAY_SECONDS * 1000); + pendingDelete.set(task.id, { timeoutId, intervalId }); }); item.appendChild(check); From c253a96d8ca3b991b2ebbc42e603da4d047711c8 Mon Sep 17 00:00:00 2001 From: gr1shan1a <368409@edu.itmo.ru> Date: Thu, 26 Feb 2026 12:03:04 +0300 Subject: [PATCH 10/15] <26.02.2026 12:03> --- frontend/src/main/resources/static/styles.css | 30 ++ frontend/src/main/resources/static/tasks.html | 5 + frontend/src/main/resources/static/tasks.js | 277 ++++++++++++------ 3 files changed, 217 insertions(+), 95 deletions(-) diff --git a/frontend/src/main/resources/static/styles.css b/frontend/src/main/resources/static/styles.css index a0dec05..4b11a7c 100644 --- a/frontend/src/main/resources/static/styles.css +++ b/frontend/src/main/resources/static/styles.css @@ -441,6 +441,31 @@ body.sidebar-open .sidebar-backdrop { gap: 10px; } +.task-tabs { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + margin-bottom: 12px; +} + +.task-tab { + border: 1px solid var(--border); + background: #fff; + color: #475569; + border-radius: 10px; + padding: 10px 8px; + font: inherit; + font-weight: 700; + text-transform: lowercase; + cursor: pointer; +} + +.task-tab.active { + background: var(--accent-soft); + border-color: #c7d2fe; + color: #1e40af; +} + .page-item { border: 1px solid var(--border); border-radius: 12px; @@ -463,6 +488,11 @@ body.sidebar-open .sidebar-backdrop { white-space: pre-wrap; } +.page-item-meta.due-urgent { + color: #dc2626; + font-weight: 600; +} + .task-check { width: 20px; height: 20px; diff --git a/frontend/src/main/resources/static/tasks.html b/frontend/src/main/resources/static/tasks.html index bdeda2c..0ab5e41 100644 --- a/frontend/src/main/resources/static/tasks.html +++ b/frontend/src/main/resources/static/tasks.html @@ -37,6 +37,11 @@

AiCal

Задачи
+
+ + + +
diff --git a/frontend/src/main/resources/static/tasks.js b/frontend/src/main/resources/static/tasks.js index dbb7e97..b290e15 100644 --- a/frontend/src/main/resources/static/tasks.js +++ b/frontend/src/main/resources/static/tasks.js @@ -1,9 +1,11 @@ (() => { const API_REQUEST_TIMEOUT_MS = 7000; const DELETE_DELAY_SECONDS = 5; + const DAY_MS = 24 * 60 * 60 * 1000; const el = { addBtn: document.getElementById("taskAddBtn"), + tabs: Array.from(document.querySelectorAll(".task-tab")), modal: document.getElementById("taskModal"), modalClose: document.getElementById("taskModalClose"), form: document.getElementById("taskForm"), @@ -13,7 +15,14 @@ list: document.getElementById("taskList"), status: document.getElementById("taskStatus") }; + + const state = { + tasks: [], + activeTab: "active" + }; + const pendingDelete = new Map(); + const taskLocks = new Set(); init(); @@ -21,21 +30,24 @@ if (!el.list) return; if (el.addBtn) { - el.addBtn.addEventListener("click", () => { - openModal(); + el.addBtn.addEventListener("click", openModal); + } + for (const tab of el.tabs) { + tab.addEventListener("click", () => { + const tabName = tab.dataset.tab; + if (!tabName || tabName === state.activeTab) return; + state.activeTab = tabName; + renderTasks(); }); } - if (el.modalClose) { el.modalClose.addEventListener("click", closeModal); } - if (el.modal) { el.modal.addEventListener("click", (e) => { if (e.target === el.modal) closeModal(); }); } - if (el.form) { el.form.addEventListener("submit", onCreate); } @@ -83,112 +95,74 @@ } async function loadTasks() { + clearPendingTimers(); setStatus("Подготавливаю задачи и синхронизирую изменения..."); const res = await requestWithFallback(getTaskEndpoints(), [{ method: "GET" }]); if (!res.success) { - renderTasks([]); + state.tasks = []; + renderTasks(); setStatus(res.message); return; } - const tasks = Array.isArray(res.data) ? res.data : []; - renderTasks(tasks.filter((t) => !t.completed)); - setStatus(tasks.length ? "" : "Пока нет задач"); + state.tasks = Array.isArray(res.data) ? res.data : []; + renderTasks(); + setStatus(state.tasks.length ? "" : "Пока нет задач"); } - function renderTasks(tasks) { + function renderTasks() { + syncTabs(); el.list.innerHTML = ""; - for (const task of tasks) { + + const filtered = state.tasks.filter((task) => { + if (state.activeTab === "done") { + return task.completed; + } + if (state.activeTab === "backlog") { + return !task.completed && !task.dueAt; + } + return !task.completed && !!task.dueAt; + }); + + for (const task of filtered) { const item = document.createElement("div"); item.className = "page-item"; const check = document.createElement("button"); check.type = "button"; check.className = "task-check"; - check.setAttribute("aria-label", "Отметить выполненной"); + check.setAttribute("aria-label", task.completed ? "Вернуть в работу" : "Отметить выполненной"); const text = document.createElement("div"); text.className = "task-text"; - const due = task.dueAt ? formatDateTime(task.dueAt) : "без срока"; - text.innerHTML = ` -
${escapeHtml(task.title || "(без названия)")}
-
${escapeHtml((task.priority || "MEDIUM") + " • " + due)}
- `; - check.addEventListener("click", async () => { - const pending = pendingDelete.get(task.id); - if (pending) { - window.clearTimeout(pending.timeoutId); - window.clearInterval(pending.intervalId); - pendingDelete.delete(task.id); - - check.disabled = false; - check.classList.remove("done"); - text.classList.remove("done"); - setStatus("Отменяю удаление..."); - - const rollbackRes = await requestWithFallback( - getTaskEndpoints().map((base) => `${base}/${task.id}`), - [ - { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ completed: false }) }, - { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ completed: false }) } - ] - ); - - if (!rollbackRes.success) { - check.classList.add("done"); - text.classList.add("done"); - setStatus(rollbackRes.message); - return; - } + const dueLabel = task.dueAt ? formatDateTime(task.dueAt) : "без срока"; + const meta = document.createElement("div"); + meta.className = "page-item-meta"; + meta.textContent = `${task.priority || "MEDIUM"} • ${dueLabel}`; + if (isDueSoon(task.dueAt) && !task.completed) { + meta.classList.add("due-urgent"); + } - setStatus("Удаление отменено"); - return; - } + const title = document.createElement("div"); + title.className = "page-item-title"; + title.textContent = task.title || "(без названия)"; - check.disabled = true; + text.appendChild(title); + text.appendChild(meta); + + const pending = pendingDelete.get(task.id); + if (task.completed) { check.classList.add("done"); text.classList.add("done"); + } + if (pending) { + check.classList.add("done"); + text.classList.add("done"); + } - const patchRes = await requestWithFallback( - getTaskEndpoints().map((base) => `${base}/${task.id}`), - [ - { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ completed: true }) }, - { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ completed: true }) } - ] - ); - - if (!patchRes.success) { - check.disabled = false; - check.classList.remove("done"); - text.classList.remove("done"); - setStatus(patchRes.message); - return; - } - - let remaining = DELETE_DELAY_SECONDS; - setStatus(`Задача выполнена. Удалю через ${remaining}... Нажмите еще раз, чтобы отменить.`); - const intervalId = window.setInterval(() => { - remaining -= 1; - if (remaining > 0) { - setStatus(`Задача выполнена. Удалю через ${remaining}... Нажмите еще раз, чтобы отменить.`); - } - }, 1000); - const timeoutId = window.setTimeout(async () => { - window.clearInterval(intervalId); - pendingDelete.delete(task.id); - await requestWithFallback( - getTaskEndpoints().map((base) => `${base}/${task.id}`), - [{ method: "DELETE" }] - ); - item.remove(); - if (!el.list.children.length) { - setStatus("Пока нет задач"); - } else { - setStatus(""); - } - }, DELETE_DELAY_SECONDS * 1000); - pendingDelete.set(task.id, { timeoutId, intervalId }); + check.addEventListener("click", async () => { + await onTaskToggle(task.id); }); item.appendChild(check); @@ -197,6 +171,128 @@ } } + async function onTaskToggle(taskId) { + if (!taskId || taskLocks.has(taskId)) return; + const task = state.tasks.find((t) => t.id === taskId); + if (!task) return; + + const pending = pendingDelete.get(taskId); + if (pending) { + await cancelPendingDelete(task); + return; + } + if (task.completed) { + await markTaskCompleted(task, false); + setStatus("Задача возвращена в работу"); + return; + } + + const ok = await markTaskCompleted(task, true); + if (!ok) return; + startDeleteCountdown(task); + } + + async function markTaskCompleted(task, completed) { + taskLocks.add(task.id); + setStatus(completed ? "Отмечаю выполненной..." : "Возвращаю в работу..."); + + const patchRes = await requestWithFallback( + getTaskEndpoints().map((base) => `${base}/${task.id}`), + [ + { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ completed }) }, + { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ completed }) } + ] + ); + + taskLocks.delete(task.id); + if (!patchRes.success) { + setStatus(patchRes.message); + return false; + } + + task.completed = completed; + renderTasks(); + return true; + } + + function startDeleteCountdown(task) { + let remaining = DELETE_DELAY_SECONDS; + setStatus(`Задача выполнена. Удалю через ${remaining}... Нажмите еще раз, чтобы отменить.`); + + const intervalId = window.setInterval(() => { + remaining -= 1; + if (remaining > 0) { + setStatus(`Задача выполнена. Удалю через ${remaining}... Нажмите еще раз, чтобы отменить.`); + } + }, 1000); + + const timeoutId = window.setTimeout(async () => { + window.clearInterval(intervalId); + pendingDelete.delete(task.id); + + const delRes = await requestWithFallback( + getTaskEndpoints().map((base) => `${base}/${task.id}`), + [{ method: "DELETE" }] + ); + + if (!delRes.success) { + setStatus(delRes.message); + return; + } + + state.tasks = state.tasks.filter((t) => t.id !== task.id); + renderTasks(); + setStatus(state.tasks.length ? "" : "Пока нет задач"); + }, DELETE_DELAY_SECONDS * 1000); + + pendingDelete.set(task.id, { timeoutId, intervalId }); + renderTasks(); + } + + async function cancelPendingDelete(task) { + const pending = pendingDelete.get(task.id); + if (!pending) return; + + window.clearTimeout(pending.timeoutId); + window.clearInterval(pending.intervalId); + pendingDelete.delete(task.id); + + const ok = await markTaskCompleted(task, false); + if (!ok) return; + setStatus("Удаление отменено"); + } + + function clearPendingTimers() { + for (const pending of pendingDelete.values()) { + window.clearTimeout(pending.timeoutId); + window.clearInterval(pending.intervalId); + } + pendingDelete.clear(); + } + + function syncTabs() { + const counts = { + active: state.tasks.filter((t) => !t.completed && !!t.dueAt).length, + done: state.tasks.filter((t) => t.completed).length, + backlog: state.tasks.filter((t) => !t.completed && !t.dueAt).length + }; + + for (const tab of el.tabs) { + const tabName = tab.dataset.tab; + if (!tabName) continue; + tab.classList.toggle("active", tabName === state.activeTab); + tab.textContent = `${tabName} ${counts[tabName] || 0}`; + } + } + + function isDueSoon(value) { + if (!value) return false; + const due = new Date(value); + if (Number.isNaN(due.getTime())) return false; + const diff = due.getTime() - Date.now(); + return diff > 0 && diff <= DAY_MS; + } + function openModal() { if (!el.modal) return; el.modal.classList.remove("hidden"); @@ -324,15 +420,6 @@ return String(n).padStart(2, "0"); } - function escapeHtml(s) { - return String(s) - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """) - .replaceAll("'", "'"); - } - async function fetchWithTimeout(resource, init, timeoutMs) { const controller = new AbortController(); const timer = window.setTimeout(() => controller.abort(), timeoutMs); From 41beeb4f93e6f9cda1057c46c5cde8178a128aba Mon Sep 17 00:00:00 2001 From: gr1shan1a <368409@edu.itmo.ru> Date: Thu, 26 Feb 2026 12:36:27 +0300 Subject: [PATCH 11/15] <26.02.2026 12:36> --- .../controller/MiniAppTaskController.java | 5 +- frontend/src/main/resources/static/styles.css | 24 +++- frontend/src/main/resources/static/tasks.js | 111 ++++++++++++++++-- 3 files changed, 126 insertions(+), 14 deletions(-) diff --git a/backend-core/src/main/java/com/aichef/controller/MiniAppTaskController.java b/backend-core/src/main/java/com/aichef/controller/MiniAppTaskController.java index 22f792c..4df6768 100644 --- a/backend-core/src/main/java/com/aichef/controller/MiniAppTaskController.java +++ b/backend-core/src/main/java/com/aichef/controller/MiniAppTaskController.java @@ -136,6 +136,9 @@ public ResponseEntity update( if (request.completed() != null) { task.setCompleted(request.completed()); } + if (Boolean.TRUE.equals(request.clearDueAt())) { + task.setDueAt(null); + } if (request.dueAt() != null) { task.setDueAt(request.dueAt()); LocalDate day = request.dueAt().atZoneSameInstant(DEFAULT_ZONE).toLocalDate(); @@ -191,7 +194,7 @@ private CalendarDay getOrCreateDay(User user, LocalDate dayDate) { public record TaskCreateRequest(String title, String priority, OffsetDateTime dueAt) { } - public record TaskUpdateRequest(String title, String priority, Boolean completed, OffsetDateTime dueAt) { + public record TaskUpdateRequest(String title, String priority, Boolean completed, OffsetDateTime dueAt, Boolean clearDueAt) { } public record TaskDto( diff --git a/frontend/src/main/resources/static/styles.css b/frontend/src/main/resources/static/styles.css index 4b11a7c..1108358 100644 --- a/frontend/src/main/resources/static/styles.css +++ b/frontend/src/main/resources/static/styles.css @@ -444,15 +444,19 @@ body.sidebar-open .sidebar-backdrop { .task-tabs { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 8px; + gap: 0; margin-bottom: 12px; + border: 1px solid var(--border); + border-radius: 12px; + overflow: hidden; + background: #fff; } .task-tab { - border: 1px solid var(--border); + border: 0; + border-right: 1px solid var(--border); background: #fff; color: #475569; - border-radius: 10px; padding: 10px 8px; font: inherit; font-weight: 700; @@ -460,12 +464,20 @@ body.sidebar-open .sidebar-backdrop { cursor: pointer; } +.task-tab:last-child { + border-right: 0; +} + .task-tab.active { background: var(--accent-soft); - border-color: #c7d2fe; color: #1e40af; } +.task-tab.drop-target { + background: #eaf1ff; + color: #1d4ed8; +} + .page-item { border: 1px solid var(--border); border-radius: 12px; @@ -476,6 +488,10 @@ body.sidebar-open .sidebar-backdrop { align-items: flex-start; } +.page-item.dragging { + opacity: 0.55; +} + .page-item-title { font-size: 16px; font-weight: 600; diff --git a/frontend/src/main/resources/static/tasks.js b/frontend/src/main/resources/static/tasks.js index b290e15..a8464a0 100644 --- a/frontend/src/main/resources/static/tasks.js +++ b/frontend/src/main/resources/static/tasks.js @@ -18,7 +18,8 @@ const state = { tasks: [], - activeTab: "active" + activeTab: "active", + draggingTaskId: null }; const pendingDelete = new Map(); @@ -39,6 +40,24 @@ state.activeTab = tabName; renderTasks(); }); + tab.addEventListener("dragover", (e) => { + if (!state.draggingTaskId) return; + e.preventDefault(); + tab.classList.add("drop-target"); + }); + tab.addEventListener("dragleave", () => { + tab.classList.remove("drop-target"); + }); + tab.addEventListener("drop", async (e) => { + if (!state.draggingTaskId) return; + e.preventDefault(); + const tabName = tab.dataset.tab; + tab.classList.remove("drop-target"); + if (!tabName) return; + await moveTaskToTab(state.draggingTaskId, tabName); + state.draggingTaskId = null; + renderTasks(); + }); } if (el.modalClose) { el.modalClose.addEventListener("click", closeModal); @@ -68,10 +87,15 @@ const title = (el.title.value || "").trim(); if (!title) return; + let dueAt = toOffsetIsoFromInput(el.dueAt.value); + if (!dueAt) { + dueAt = toOffsetIso(new Date(Date.now() + 60 * 60 * 1000)); + } + const payload = { title, priority: el.priority.value || "MEDIUM", - dueAt: toOffsetIsoFromInput(el.dueAt.value) + dueAt }; setStatus("Сохраняю..."); @@ -127,6 +151,16 @@ for (const task of filtered) { const item = document.createElement("div"); item.className = "page-item"; + item.draggable = true; + item.addEventListener("dragstart", () => { + state.draggingTaskId = task.id; + item.classList.add("dragging"); + }); + item.addEventListener("dragend", () => { + state.draggingTaskId = null; + item.classList.remove("dragging"); + clearDropTargets(); + }); const check = document.createElement("button"); check.type = "button"; @@ -192,17 +226,46 @@ startDeleteCountdown(task); } + async function moveTaskToTab(taskId, tabName) { + const task = state.tasks.find((t) => t.id === taskId); + if (!task || taskLocks.has(taskId)) return; + + if (tabName === "done") { + const ok = await markTaskCompleted(task, true); + if (ok) setStatus("Задача перемещена в done"); + return; + } + + if (tabName === "backlog") { + const ok = await patchTask(task.id, { completed: false, clearDueAt: true }); + if (!ok.success) { + setStatus(ok.message); + return; + } + task.completed = false; + task.dueAt = null; + setStatus("Задача перемещена в backlog"); + return; + } + + if (tabName === "active") { + const nextDue = task.dueAt || toOffsetIso(new Date(Date.now() + 60 * 60 * 1000)); + const ok = await patchTask(task.id, { completed: false, dueAt: nextDue }); + if (!ok.success) { + setStatus(ok.message); + return; + } + task.completed = false; + task.dueAt = nextDue; + setStatus("Задача перемещена в active"); + } + } + async function markTaskCompleted(task, completed) { taskLocks.add(task.id); setStatus(completed ? "Отмечаю выполненной..." : "Возвращаю в работу..."); - const patchRes = await requestWithFallback( - getTaskEndpoints().map((base) => `${base}/${task.id}`), - [ - { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ completed }) }, - { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ completed }) } - ] - ); + const patchRes = await patchTask(task.id, { completed }); taskLocks.delete(task.id); if (!patchRes.success) { @@ -215,6 +278,16 @@ return true; } + async function patchTask(taskId, payload) { + return requestWithFallback( + getTaskEndpoints().map((base) => `${base}/${taskId}`), + [ + { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }, + { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) } + ] + ); + } + function startDeleteCountdown(task) { let remaining = DELETE_DELAY_SECONDS; setStatus(`Задача выполнена. Удалю через ${remaining}... Нажмите еще раз, чтобы отменить.`); @@ -285,6 +358,12 @@ } } + function clearDropTargets() { + for (const tab of el.tabs) { + tab.classList.remove("drop-target"); + } + } + function isDueSoon(value) { if (!value) return false; const due = new Date(value); @@ -420,6 +499,20 @@ return String(n).padStart(2, "0"); } + function toOffsetIso(date) { + if (!(date instanceof Date) || Number.isNaN(date.getTime())) return null; + const y = date.getFullYear(); + const m = pad2(date.getMonth() + 1); + const d = pad2(date.getDate()); + const hh = pad2(date.getHours()); + const mm = pad2(date.getMinutes()); + const ss = pad2(date.getSeconds()); + const off = -date.getTimezoneOffset(); + const sign = off >= 0 ? "+" : "-"; + const abs = Math.abs(off); + return `${y}-${m}-${d}T${hh}:${mm}:${ss}${sign}${pad2(Math.floor(abs / 60))}:${pad2(abs % 60)}`; + } + async function fetchWithTimeout(resource, init, timeoutMs) { const controller = new AbortController(); const timer = window.setTimeout(() => controller.abort(), timeoutMs); From a98ca0cad7b7413a485aaf8e2dd20d7a9f362d63 Mon Sep 17 00:00:00 2001 From: gr1shan1a <368409@edu.itmo.ru> Date: Thu, 26 Feb 2026 12:57:35 +0300 Subject: [PATCH 12/15] <26.02.2026 12:57> --- .../service/MessageUnderstandingService.java | 113 +++++++++++++++++- .../OllamaStructuredParsingService.java | 10 +- 2 files changed, 117 insertions(+), 6 deletions(-) diff --git a/backend-core/src/main/java/com/aichef/service/MessageUnderstandingService.java b/backend-core/src/main/java/com/aichef/service/MessageUnderstandingService.java index 91e59f0..2b97d5a 100644 --- a/backend-core/src/main/java/com/aichef/service/MessageUnderstandingService.java +++ b/backend-core/src/main/java/com/aichef/service/MessageUnderstandingService.java @@ -190,6 +190,47 @@ public MessageIntent decide(String sourceText, ZoneId zoneId) { ); } + if (llmParsed.isCreateTaskIntent()) { + OffsetDateTime dueAt = inferTaskDue(normalized, zoneId); + String title = llmParsed.title() == null || llmParsed.title().isBlank() + ? cleanupTaskTitle(text) + : cleanupTaskTitle(llmParsed.title()); + return new MessageIntent( + BotAction.CREATE_TASK, + FilterClassification.TASK, + InboundStatus.PROCESSED, + title, + PriorityLevel.MEDIUM, + null, + null, + dueAt, + null, + null, + null, + link, + "✅ Задача добавлена: " + title + ); + } + + if (llmParsed.isCreateNoteIntent()) { + String noteTitle = cleanupTitle(stripTaskCommandPhrases(stripCreateCommandPhrases(text)), "Заметка"); + return new MessageIntent( + BotAction.CREATE_NOTE, + FilterClassification.INFO_ONLY, + InboundStatus.PROCESSED, + noteTitle, + PriorityLevel.LOW, + null, + null, + null, + null, + null, + text, + link, + "📝 Сохранил как заметку." + ); + } + if (hasMeetingHint) { OffsetDateTime start = inferMeetingStart(normalized, zoneId); OffsetDateTime end = start.plusHours(1); @@ -213,7 +254,7 @@ public MessageIntent decide(String sourceText, ZoneId zoneId) { if (hasTaskHint) { OffsetDateTime dueAt = inferTaskDue(normalized, zoneId); - String title = cleanupTitle(text, "Задача"); + String title = cleanupTaskTitle(text); return new MessageIntent( BotAction.CREATE_TASK, FilterClassification.TASK, @@ -231,7 +272,7 @@ public MessageIntent decide(String sourceText, ZoneId zoneId) { ); } - String noteTitle = cleanupTitle(text, "Заметка"); + String noteTitle = cleanupTitle(stripTaskCommandPhrases(stripCreateCommandPhrases(text)), "Заметка"); return new MessageIntent( BotAction.CREATE_NOTE, FilterClassification.INFO_ONLY, @@ -526,13 +567,43 @@ private String cleanupMeetingTitle(String text) { return cleanupTitle(title, "Встреча"); } + private String cleanupTaskTitle(String text) { + String title = text == null ? "" : text; + title = stripTaskCommandPhrases(title); + title = stripCreateCommandPhrases(title); + title = title.replaceAll("(?iu)^\\s*(задач\\p{L}*|task)\\s*[:\\-]?\\s*", ""); + title = title.replaceAll("(?iu)\\b(сегодня|завтра|послезавтра|today|tomorrow)\\b", " "); + title = title.replaceAll("(?iu)\\b(до|к)\\s+\\d{1,2}[:.]\\d{2}\\b", " "); + title = title.replaceAll("(?iu)\\bв\\s+\\d{1,2}[:.]\\d{2}\\b", " "); + title = title.replaceAll("(?iu)\\b\\d{1,2}[./]\\d{1,2}(?:[./]\\d{2,4})?\\b", " "); + title = title.replaceAll("\\s+", " ").trim(); + return cleanupTitle(title, "Задача"); + } + private String stripCreateCommandPhrases(String source) { if (source == null) { return ""; } String cleaned = source; - cleaned = cleaned.replaceAll("(?iu)\\b(созда(й|ть)|добав(ь|ить)|запланиру(й|йте|ю)|сдела(й|ть))\\s+(мне\\s+)?(событи\\p{L}*|встреч\\p{L}*)\\b", " "); - cleaned = cleaned.replaceAll("(?iu)\\b(созда(й|ть)|добав(ь|ить)|запланиру(й|йте|ю)|сдела(й|ть))\\b", " "); + cleaned = cleaned.replaceAll("(?iu)\\b(созда\\p{L}*|добав\\p{L}*|запланиру\\p{L}*|сдела\\p{L}*)\\s+(мне\\s+)?(событи\\p{L}*|встреч\\p{L}*)\\b", " "); + cleaned = cleaned.replaceAll("(?iu)\\b(созда\\p{L}*|добав\\p{L}*|запланиру\\p{L}*|сдела\\p{L}*)\\b", " "); + cleaned = cleaned.replaceAll("\\s+", " ").trim(); + return cleaned; + } + + private String stripTaskCommandPhrases(String source) { + if (source == null) { + return ""; + } + String cleaned = source; + cleaned = cleaned.replaceAll( + "(?iu)^\\s*(?:ну\\s+)?(?:пожалуйста\\s+)?(?:созда\\p{L}*|добав\\p{L}*|сдела\\p{L}*)\\s+(?:мне\\s+)?(?:задач\\p{L}*|task)\\s*", + " " + ); + cleaned = cleaned.replaceAll( + "(?iu)\\b(?:созда\\p{L}*|добав\\p{L}*|сдела\\p{L}*)\\s+(?:мне\\s+)?(?:задач\\p{L}*|task)\\b", + " " + ); cleaned = cleaned.replaceAll("\\s+", " ").trim(); return cleaned; } @@ -579,7 +650,10 @@ private OffsetDateTime inferMeetingStart(String normalized, ZoneId zoneId) { private OffsetDateTime inferTaskDue(String normalized, ZoneId zoneId) { LocalDate date = inferDate(normalized, zoneId); - LocalTime time = hasAny(normalized, "сегодня", "today") ? LocalTime.of(20, 0) : LocalTime.of(12, 0); + LocalTime explicit = extractExplicitTime(normalized); + LocalTime time = explicit != null + ? explicit + : OffsetDateTime.now(zoneId).toLocalTime().withSecond(0).withNano(0); return OffsetDateTime.now(zoneId) .withYear(date.getYear()) .withMonth(date.getMonthValue()) @@ -590,6 +664,35 @@ private OffsetDateTime inferTaskDue(String normalized, ZoneId zoneId) { .withNano(0); } + private LocalTime extractExplicitTime(String normalized) { + String withoutDates = normalized.replaceAll("\\b\\d{1,2}[.]\\d{1,2}(?:[.]\\d{2,4})?\\b", " "); + Matcher timeMatcher = TIME_COLON_PATTERN.matcher(withoutDates); + while (timeMatcher.find()) { + int hour = Integer.parseInt(timeMatcher.group(1)); + int minute = Integer.parseInt(timeMatcher.group(2)); + if (hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59) { + return LocalTime.of(hour, minute); + } + } + + Matcher hourMatcher = TIME_HOURS_PATTERN.matcher(withoutDates); + while (hourMatcher.find()) { + int hour = Integer.parseInt(hourMatcher.group(1)); + if (hour >= 0 && hour <= 23) { + return LocalTime.of(hour, 0); + } + } + + Matcher hourWordsMatcher = HOUR_WORDS_PATTERN.matcher(withoutDates); + while (hourWordsMatcher.find()) { + Integer hour = parseRussianWordsNumber(hourWordsMatcher.group(1)); + if (hour != null && hour >= 0 && hour <= 23) { + return LocalTime.of(hour, 0); + } + } + return null; + } + private LocalDate inferDate(String normalized, ZoneId zoneId) { LocalDate now = LocalDate.now(zoneId); if (hasAny(normalized, "сегодня", "today")) { diff --git a/backend-core/src/main/java/com/aichef/service/OllamaStructuredParsingService.java b/backend-core/src/main/java/com/aichef/service/OllamaStructuredParsingService.java index 3931b33..490b230 100644 --- a/backend-core/src/main/java/com/aichef/service/OllamaStructuredParsingService.java +++ b/backend-core/src/main/java/com/aichef/service/OllamaStructuredParsingService.java @@ -65,7 +65,7 @@ private String buildPrompt(String today, String text) { today=%s Схема: { - "intent":"create_meeting|create_task|other", + "intent":"create_meeting|create_task|create_note|other", "title":"string|null", "date":"YYYY-MM-DD|null", "time":"HH:mm|null", @@ -239,5 +239,13 @@ public boolean hasAnyData() { public boolean isCreateMeetingIntent() { return intent != null && intent.equalsIgnoreCase("create_meeting"); } + + public boolean isCreateTaskIntent() { + return intent != null && intent.equalsIgnoreCase("create_task"); + } + + public boolean isCreateNoteIntent() { + return intent != null && intent.equalsIgnoreCase("create_note"); + } } } From a64d71f24183f3a4838df7abd03988d580791328 Mon Sep 17 00:00:00 2001 From: gr1shan1a <368409@edu.itmo.ru> Date: Thu, 26 Feb 2026 13:36:47 +0300 Subject: [PATCH 13/15] <26.02.2026 13:36> --- .../controller/MiniAppNoteController.java | 6 +- .../aichef/service/TelegramBotService.java | 5 +- frontend/src/main/resources/static/app.js | 23 ++++- frontend/src/main/resources/static/common.js | 23 ++++- frontend/src/main/resources/static/index.html | 5 +- frontend/src/main/resources/static/notes.html | 5 +- .../src/main/resources/static/profile.html | 62 ++++++++++++ frontend/src/main/resources/static/profile.js | 99 +++++++++++++++++++ frontend/src/main/resources/static/styles.css | 37 +++++++ frontend/src/main/resources/static/tasks.html | 5 +- 10 files changed, 256 insertions(+), 14 deletions(-) create mode 100644 frontend/src/main/resources/static/profile.html create mode 100644 frontend/src/main/resources/static/profile.js diff --git a/backend-core/src/main/java/com/aichef/controller/MiniAppNoteController.java b/backend-core/src/main/java/com/aichef/controller/MiniAppNoteController.java index cae561d..1b6fef3 100644 --- a/backend-core/src/main/java/com/aichef/controller/MiniAppNoteController.java +++ b/backend-core/src/main/java/com/aichef/controller/MiniAppNoteController.java @@ -59,15 +59,15 @@ public ResponseEntity create( return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Unauthorized"); } - if (request.title() == null || request.title().isBlank() - || request.content() == null || request.content().isBlank()) { + if (request.title() == null || request.title().isBlank()) { return ResponseEntity.badRequest().body("Missing required fields"); } Note note = new Note(); note.setUser(userOpt.get()); note.setTitle(TextNormalization.normalizeRussian(request.title().trim())); - note.setContent(TextNormalization.normalizeRussian(request.content().trim())); + String rawContent = request.content() == null ? "" : request.content(); + note.setContent(TextNormalization.normalizeRussian(rawContent.trim())); note.setArchived(false); noteRepository.save(note); diff --git a/backend-core/src/main/java/com/aichef/service/TelegramBotService.java b/backend-core/src/main/java/com/aichef/service/TelegramBotService.java index 4fd83bf..7c17899 100644 --- a/backend-core/src/main/java/com/aichef/service/TelegramBotService.java +++ b/backend-core/src/main/java/com/aichef/service/TelegramBotService.java @@ -640,7 +640,10 @@ private String applyIntent(User user, InboundItem inboundItem, MessageIntent int note.setContent(""); } noteRepository.save(note); - return "📝 Заметка сохранена.\nID: " + note.getId(); + log.info("Note created from bot. userId={}, telegramId={}, noteId={}, title={}", + user.getId(), user.getTelegramId(), note.getId(), note.getTitle()); + String preview = note.getTitle() == null || note.getTitle().isBlank() ? "Заметка" : note.getTitle(); + return "📝 Заметка сохранена\nНазвание: " + preview; } if (intent.action() == BotAction.EDIT_NOTE) { diff --git a/frontend/src/main/resources/static/app.js b/frontend/src/main/resources/static/app.js index e3fbe5b..d097252 100644 --- a/frontend/src/main/resources/static/app.js +++ b/frontend/src/main/resources/static/app.js @@ -2,7 +2,8 @@ const menuItems = [ { icon: "▦", label: "Расписание", href: "index.html", key: "schedule" }, { icon: "✓", label: "Задачи", href: "tasks.html", key: "tasks" }, - { icon: "✎", label: "Заметки", href: "notes.html", key: "notes" } + { icon: "✎", label: "Заметки", href: "notes.html", key: "notes" }, + { icon: "◉", label: "Профиль", href: "profile.html", key: "profile" } ]; const dayNames = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"]; @@ -182,7 +183,8 @@ const tgUser = tg && tg.initDataUnsafe ? tg.initDataUnsafe.user : null; const userId = tgUser && tgUser.id ? String(tgUser.id) : getTelegramIdFromContext(); const photoUrl = tgUser && tgUser.photo_url ? String(tgUser.photo_url) : ""; - const username = tgUser && tgUser.username ? `@${tgUser.username}` : ""; + const usernameRaw = tgUser && tgUser.username ? String(tgUser.username) : ""; + const username = usernameRaw ? `@${usernameRaw}` : ""; const displayName = username || (userId ? `id${userId}` : "user"); if (el.userName) { @@ -210,10 +212,27 @@ img.style.objectFit = "cover"; el.userAvatar.textContent = ""; el.userAvatar.appendChild(img); + attachProfileNavigation(usernameRaw); return; } el.userAvatar.textContent = userId ? String(userId).slice(-2) : "ID"; + attachProfileNavigation(usernameRaw); + } + + function attachProfileNavigation(usernameRaw) { + const card = document.querySelector(".user-card"); + if (!card) return; + const profileUrl = `profile.html${window.location.search || ""}`; + card.classList.add("profile-link"); + card.addEventListener("click", () => { + const tg = window.Telegram && window.Telegram.WebApp ? window.Telegram.WebApp : null; + if (tg && typeof tg.openTelegramLink === "function" && usernameRaw) { + tg.openTelegramLink(`https://t.me/${usernameRaw}`); + return; + } + window.location.href = profileUrl; + }); } async function resolveTitlePrefix() { diff --git a/frontend/src/main/resources/static/common.js b/frontend/src/main/resources/static/common.js index 6c594c3..a4a3a5e 100644 --- a/frontend/src/main/resources/static/common.js +++ b/frontend/src/main/resources/static/common.js @@ -2,7 +2,8 @@ const menuItems = [ { icon: "▦", label: "Расписание", href: "index.html", key: "schedule" }, { icon: "✓", label: "Задачи", href: "tasks.html", key: "tasks" }, - { icon: "✎", label: "Заметки", href: "notes.html", key: "notes" } + { icon: "✎", label: "Заметки", href: "notes.html", key: "notes" }, + { icon: "◉", label: "Профиль", href: "profile.html", key: "profile" } ]; const page = document.body.dataset.page || "schedule"; @@ -67,7 +68,8 @@ const tgUser = tg && tg.initDataUnsafe ? tg.initDataUnsafe.user : null; const userId = tgUser && tgUser.id ? String(tgUser.id) : getTelegramIdFromContext(); const photoUrl = tgUser && tgUser.photo_url ? String(tgUser.photo_url) : ""; - const username = tgUser && tgUser.username ? `@${tgUser.username}` : ""; + const usernameRaw = tgUser && tgUser.username ? String(tgUser.username) : ""; + const username = usernameRaw ? `@${usernameRaw}` : ""; const displayName = username || (userId ? `id${userId}` : "user"); if (el.userName) { @@ -95,10 +97,27 @@ img.style.objectFit = "cover"; el.userAvatar.textContent = ""; el.userAvatar.appendChild(img); + attachProfileNavigation(usernameRaw); return; } el.userAvatar.textContent = userId ? String(userId).slice(-2) : "ID"; + attachProfileNavigation(usernameRaw); + } + + function attachProfileNavigation(usernameRaw) { + const card = document.querySelector(".user-card"); + if (!card) return; + const profileUrl = `profile.html${window.location.search || ""}`; + card.classList.add("profile-link"); + card.addEventListener("click", () => { + const tg = window.Telegram && window.Telegram.WebApp ? window.Telegram.WebApp : null; + if (tg && typeof tg.openTelegramLink === "function" && usernameRaw) { + tg.openTelegramLink(`https://t.me/${usernameRaw}`); + return; + } + window.location.href = profileUrl; + }); } function getTelegramIdFromContext() { diff --git a/frontend/src/main/resources/static/index.html b/frontend/src/main/resources/static/index.html index 805b17c..905416a 100644 --- a/frontend/src/main/resources/static/index.html +++ b/frontend/src/main/resources/static/index.html @@ -3,7 +3,7 @@ - AI Chef Schedule + Impera Vox Schedule @@ -20,7 +20,7 @@
-

AiCal

+

Impera Vox

ID
@@ -43,6 +43,7 @@

AiCal

+
all rights reserved. ur impera vox
diff --git a/frontend/src/main/resources/static/notes.html b/frontend/src/main/resources/static/notes.html index 66f0d4e..c8794af 100644 --- a/frontend/src/main/resources/static/notes.html +++ b/frontend/src/main/resources/static/notes.html @@ -3,7 +3,7 @@ - AiCal Notes + Impera Vox Notes @@ -20,7 +20,7 @@
-

AiCal

+

Impera Vox

ID
@@ -40,6 +40,7 @@

AiCal

+
all rights reserved. ur impera vox
diff --git a/frontend/src/main/resources/static/profile.html b/frontend/src/main/resources/static/profile.html new file mode 100644 index 0000000..39fd86c --- /dev/null +++ b/frontend/src/main/resources/static/profile.html @@ -0,0 +1,62 @@ + + + + + + Impera Vox Profile + + + + + + + + +
+ + +
+
+ +

Impera Vox

+
+
+
ID
+
+
Mr @username
+
+
+
+
+
+ +
+
Профиль
+
+
+
+
Ник
+
-
+
+
+
Префикс
+
-
+
+
+
Часовой пояс
+
-
+
+
+
+
all rights reserved. ur impera vox
+
+
+ + + + + + + diff --git a/frontend/src/main/resources/static/profile.js b/frontend/src/main/resources/static/profile.js new file mode 100644 index 0000000..c009057 --- /dev/null +++ b/frontend/src/main/resources/static/profile.js @@ -0,0 +1,99 @@ +(() => { + const API_REQUEST_TIMEOUT_MS = 7000; + + const el = { + status: document.getElementById("profileStatus"), + username: document.getElementById("profileUsername"), + prefix: document.getElementById("profilePrefix"), + timezone: document.getElementById("profileTimezone") + }; + + init(); + + async function init() { + const common = window.AiCalCommon; + if (common && typeof common.wakeUpServices === "function") { + await common.wakeUpServices((msg) => setStatus(msg)); + } + await loadProfile(); + } + + async function loadProfile() { + setStatus("Подготавливаю профиль..."); + const res = await requestWithFallback(["/api/miniapp/me"], [{ method: "GET" }]); + if (!res.success) { + setStatus(res.message); + return; + } + const data = res.data || {}; + if (el.username) { + const user = data.username ? `@${data.username}` : "-"; + el.username.textContent = user; + } + if (el.prefix) { + el.prefix.textContent = data.titlePrefix || "-"; + } + if (el.timezone) { + el.timezone.textContent = data.timezone || "-"; + } + setStatus(""); + } + + async function request(path, init = {}, forcedBase = "") { + const common = window.AiCalCommon; + const base = forcedBase || (common && common.getApiBaseUrl ? common.getApiBaseUrl() : window.location.origin); + const auth = common && common.buildAuth ? common.buildAuth() : { headers: {}, telegramId: "" }; + const url = new URL(base + path); + if (auth.telegramId) { + url.searchParams.set("telegramId", auth.telegramId); + } + try { + const response = await fetchWithTimeout(url.toString(), { ...init, headers: { ...(init.headers || {}), ...auth.headers } }, API_REQUEST_TIMEOUT_MS); + if (!response.ok) { + return { success: false, status: response.status, message: apiErrorMessage(response.status) }; + } + const data = await response.json().catch(() => null); + return { success: true, data }; + } catch { + return { success: false, message: "Ошибка сети" }; + } + } + + async function requestWithFallback(paths, variants) { + let lastError = { success: false, message: "Ошибка API" }; + const common = window.AiCalCommon; + const bases = common && typeof common.getApiBaseCandidates === "function" + ? common.getApiBaseCandidates() + : [window.location.origin]; + for (const base of bases) { + for (const path of paths) { + for (const variant of variants) { + const res = await request(path, variant, base); + if (res.success) return res; + lastError = res; + } + } + } + return lastError; + } + + function apiErrorMessage(status) { + if (status === 401) return "Нет доступа. Открой Mini App через Telegram"; + if (status === 404) return "Профиль временно недоступен."; + return `Ошибка API (${status})`; + } + + function setStatus(message) { + if (el.status) el.status.textContent = message || ""; + } + + async function fetchWithTimeout(resource, init, timeoutMs) { + const controller = new AbortController(); + const timer = window.setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(resource, { ...init, signal: controller.signal }); + } finally { + window.clearTimeout(timer); + } + } +})(); diff --git a/frontend/src/main/resources/static/styles.css b/frontend/src/main/resources/static/styles.css index 1108358..b75efc9 100644 --- a/frontend/src/main/resources/static/styles.css +++ b/frontend/src/main/resources/static/styles.css @@ -166,6 +166,10 @@ body.sidebar-open .sidebar-backdrop { gap: 10px; } +.user-card.profile-link { + cursor: pointer; +} + .avatar { width: 38px; height: 38px; @@ -733,6 +737,39 @@ body.sidebar-open .sidebar-backdrop { border-radius: 8px; } +.app-footer { + margin: 0 16px 12px; + border: 1px solid var(--border); + border-radius: 12px; + padding: 8px 12px; + color: #94a3b8; + font-size: 12px; + text-transform: lowercase; + text-align: center; +} + +.profile-grid { + display: grid; + gap: 10px; +} + +.profile-item { + border: 1px solid var(--border); + border-radius: 12px; + padding: 12px; +} + +.profile-item-label { + font-size: 12px; + color: var(--muted); +} + +.profile-item-value { + margin-top: 4px; + font-size: 16px; + font-weight: 600; +} + @media (max-width: 980px) { :root { --sidebar-width: 220px; diff --git a/frontend/src/main/resources/static/tasks.html b/frontend/src/main/resources/static/tasks.html index 0ab5e41..cb34827 100644 --- a/frontend/src/main/resources/static/tasks.html +++ b/frontend/src/main/resources/static/tasks.html @@ -3,7 +3,7 @@ - AiCal Tasks + Impera Vox Tasks @@ -20,7 +20,7 @@
-

AiCal

+

Impera Vox

ID
@@ -45,6 +45,7 @@

AiCal

+
all rights reserved. ur impera vox
From a38f933bcb44d847afc8cb4de3a2448b93ca2976 Mon Sep 17 00:00:00 2001 From: gr1shan1a <368409@edu.itmo.ru> Date: Thu, 26 Feb 2026 14:05:06 +0300 Subject: [PATCH 14/15] <26.02.2026 14:04> --- .../controller/MiniAppMeController.java | 69 ++++++++++- .../controller/MiniAppMeetingController.java | 6 +- .../controller/MiniAppNoteController.java | 19 +++ frontend/src/main/resources/static/app.js | 27 +++- frontend/src/main/resources/static/notes.html | 13 ++ frontend/src/main/resources/static/notes.js | 69 ++++++++++- .../src/main/resources/static/profile.html | 8 ++ frontend/src/main/resources/static/profile.js | 92 +++++++++++++- frontend/src/main/resources/static/styles.css | 116 ++++++++++++++++++ frontend/src/main/resources/static/tasks.js | 113 +++++++++++++++-- 10 files changed, 510 insertions(+), 22 deletions(-) diff --git a/backend-core/src/main/java/com/aichef/controller/MiniAppMeController.java b/backend-core/src/main/java/com/aichef/controller/MiniAppMeController.java index dfa9d23..0fe6db8 100644 --- a/backend-core/src/main/java/com/aichef/controller/MiniAppMeController.java +++ b/backend-core/src/main/java/com/aichef/controller/MiniAppMeController.java @@ -2,17 +2,23 @@ import com.aichef.domain.enums.Gender; import com.aichef.domain.model.User; +import com.aichef.domain.model.UserGoogleConnection; +import com.aichef.repository.UserRepository; +import com.aichef.repository.UserGoogleConnectionRepository; import com.aichef.service.MiniAppAuthService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.time.ZoneId; import java.util.Optional; import java.util.UUID; @@ -23,6 +29,8 @@ public class MiniAppMeController { private final MiniAppAuthService miniAppAuthService; + private final UserRepository userRepository; + private final UserGoogleConnectionRepository userGoogleConnectionRepository; @GetMapping("/me") public ResponseEntity me( @@ -40,7 +48,44 @@ public ResponseEntity me( Gender gender = user.getGender() == null ? Gender.UNKNOWN : user.getGender(); log.info("MiniApp profile loaded. userId={}, telegramId={}, gender={}", user.getId(), user.getTelegramId(), gender); - return ResponseEntity.ok(new MeResponse(user.getId(), user.getTelegramId(), gender, toTitlePrefix(gender))); + UserGoogleConnection conn = userGoogleConnectionRepository.findByUser(user).orElse(null); + boolean googleConnected = conn != null + && ((conn.getRefreshToken() != null && !conn.getRefreshToken().isBlank()) + || (conn.getAccessToken() != null && !conn.getAccessToken().isBlank())); + boolean icsConnected = conn != null && conn.getIcsToken() != null && !conn.getIcsToken().isBlank(); + return ResponseEntity.ok(new MeResponse( + user.getId(), + user.getTelegramId(), + gender, + toTitlePrefix(gender), + user.getTimezone(), + new CalendarConnections(true, googleConnected, icsConnected) + )); + } + + @PatchMapping("/me/timezone") + public ResponseEntity updateTimezone( + @RequestBody TimezoneUpdateRequest request, + @RequestHeader(value = "X-Telegram-Init-Data", required = false) String initData, + @RequestParam(value = "telegramId", required = false) Long telegramId + ) { + Optional userOpt = miniAppAuthService.resolveUser(initData, telegramId); + if (userOpt.isEmpty()) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Unauthorized"); + } + String timezone = request == null || request.timezone() == null ? "" : request.timezone().trim(); + if (timezone.isBlank()) { + return ResponseEntity.badRequest().body("timezone is required"); + } + try { + ZoneId.of(timezone); + } catch (Exception e) { + return ResponseEntity.badRequest().body("Invalid timezone"); + } + User user = userOpt.get(); + user.setTimezone(timezone); + userRepository.save(user); + return ResponseEntity.ok(new TimezoneUpdateResponse(timezone)); } private String toTitlePrefix(Gender gender) { @@ -50,6 +95,26 @@ private String toTitlePrefix(Gender gender) { return "Mr"; } - public record MeResponse(UUID id, Long telegramId, Gender gender, String titlePrefix) { + public record MeResponse( + UUID id, + Long telegramId, + Gender gender, + String titlePrefix, + String timezone, + CalendarConnections calendars + ) { + } + + public record CalendarConnections( + boolean internal, + boolean google, + boolean ical + ) { + } + + public record TimezoneUpdateRequest(String timezone) { + } + + public record TimezoneUpdateResponse(String timezone) { } } diff --git a/backend-core/src/main/java/com/aichef/controller/MiniAppMeetingController.java b/backend-core/src/main/java/com/aichef/controller/MiniAppMeetingController.java index 17a63b6..f342aaa 100644 --- a/backend-core/src/main/java/com/aichef/controller/MiniAppMeetingController.java +++ b/backend-core/src/main/java/com/aichef/controller/MiniAppMeetingController.java @@ -317,7 +317,8 @@ public record MeetingDto( OffsetDateTime endsAt, String location, String externalLink, - String color + String color, + String googleEventId ) { public static MeetingDto from(Meeting meeting) { return new MeetingDto( @@ -327,7 +328,8 @@ public static MeetingDto from(Meeting meeting) { meeting.getEndsAt(), TextNormalization.normalizeRussian(meeting.getLocation()), TextNormalization.normalizeRussian(meeting.getExternalLink()), - TextNormalization.normalizeRussian(meeting.getColor()) + TextNormalization.normalizeRussian(meeting.getColor()), + TextNormalization.normalizeRussian(meeting.getGoogleEventId()) ); } } diff --git a/backend-core/src/main/java/com/aichef/controller/MiniAppNoteController.java b/backend-core/src/main/java/com/aichef/controller/MiniAppNoteController.java index 1b6fef3..2662299 100644 --- a/backend-core/src/main/java/com/aichef/controller/MiniAppNoteController.java +++ b/backend-core/src/main/java/com/aichef/controller/MiniAppNoteController.java @@ -77,6 +77,25 @@ public ResponseEntity create( public record NoteCreateRequest(String title, String content) { } + @DeleteMapping("/{id}") + public ResponseEntity delete( + @PathVariable("id") UUID id, + @RequestHeader(value = "X-Telegram-Init-Data", required = false) String initData, + @RequestParam(value = "telegramId", required = false) Long telegramId + ) { + Optional userOpt = miniAppAuthService.resolveUser(initData, telegramId); + if (userOpt.isEmpty()) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Unauthorized"); + } + Note note = noteRepository.findByIdAndUser(id, userOpt.get()).orElse(null); + if (note == null) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body("Note not found"); + } + note.setArchived(true); + noteRepository.save(note); + return ResponseEntity.noContent().build(); + } + public record NoteDto( UUID id, String title, diff --git a/frontend/src/main/resources/static/app.js b/frontend/src/main/resources/static/app.js index d097252..22aa460 100644 --- a/frontend/src/main/resources/static/app.js +++ b/frontend/src/main/resources/static/app.js @@ -11,6 +11,7 @@ const API_REQUEST_TIMEOUT_MS = 7000; const PROFILE_REQUEST_TIMEOUT_MS = 1500; const TELEGRAM_ID_STORAGE_KEY = "aical_telegram_id"; + const CALENDAR_VISIBILITY_KEY = "impera_calendar_visibility"; const WAKEUP_TIMEOUT_MS = 45000; const WAKEUP_STEP_MS = 2500; @@ -302,12 +303,36 @@ return; } - state.meetings = Array.isArray(res.data) ? res.data : []; + const rawMeetings = Array.isArray(res.data) ? res.data : []; + state.meetings = filterMeetingsByVisibility(rawMeetings); renderWeekSkeleton(); renderMeetings(); setScheduleStatus(state.meetings.length ? "" : "Событий на неделю нет"); } + function filterMeetingsByVisibility(meetings) { + const visibility = readCalendarVisibility(); + const internalOn = visibility.internal !== false; + const googleOn = visibility.google !== false; + const icalOn = visibility.ical !== false; + return meetings.filter((m) => { + const isGoogleLinked = !!(m && m.googleEventId); + if (isGoogleLinked && !googleOn) return false; + if (!isGoogleLinked && (!internalOn || !icalOn)) return false; + return true; + }); + } + + function readCalendarVisibility() { + try { + const parsed = JSON.parse(localStorage.getItem(CALENDAR_VISIBILITY_KEY) || "{}"); + if (!parsed || typeof parsed !== "object") return {}; + return parsed; + } catch { + return {}; + } + } + function renderWeekSkeleton() { renderRange(); renderHead(); diff --git a/frontend/src/main/resources/static/notes.html b/frontend/src/main/resources/static/notes.html index c8794af..e7439cd 100644 --- a/frontend/src/main/resources/static/notes.html +++ b/frontend/src/main/resources/static/notes.html @@ -58,6 +58,19 @@

Impera Vox

+ + diff --git a/frontend/src/main/resources/static/notes.js b/frontend/src/main/resources/static/notes.js index 7de9e02..1e98be6 100644 --- a/frontend/src/main/resources/static/notes.js +++ b/frontend/src/main/resources/static/notes.js @@ -9,7 +9,16 @@ title: document.getElementById("noteTitle"), content: document.getElementById("noteContent"), list: document.getElementById("noteList"), - status: document.getElementById("noteStatus") + status: document.getElementById("noteStatus"), + viewModal: document.getElementById("noteViewModal"), + viewTitle: document.getElementById("noteViewTitle"), + viewContent: document.getElementById("noteViewContent"), + viewClose: document.getElementById("noteViewClose"), + deleteBtn: document.getElementById("noteDeleteBtn") + }; + const state = { + notes: [], + activeNoteId: "" }; init(); @@ -34,6 +43,17 @@ if (el.form) { el.form.addEventListener("submit", onCreate); } + if (el.viewClose) { + el.viewClose.addEventListener("click", closeViewModal); + } + if (el.viewModal) { + el.viewModal.addEventListener("click", (e) => { + if (e.target === el.viewModal) closeViewModal(); + }); + } + if (el.deleteBtn) { + el.deleteBtn.addEventListener("click", onDeleteActiveNote); + } bootAndLoadNotes(); } @@ -81,20 +101,21 @@ return; } - const notes = Array.isArray(res.data) ? res.data : []; - renderNotes(notes); - setStatus(notes.length ? "" : "Пока нет заметок"); + state.notes = Array.isArray(res.data) ? res.data : []; + renderNotes(state.notes); + setStatus(state.notes.length ? "" : "Пока нет заметок"); } function renderNotes(notes) { el.list.innerHTML = ""; for (const n of notes) { const item = document.createElement("div"); - item.className = "page-item"; + item.className = "page-item note-item"; item.innerHTML = `
${escapeHtml(n.title || "(без названия)")}
${escapeHtml(n.content || "")}
`; + item.addEventListener("click", () => openViewModal(n.id)); el.list.appendChild(item); } } @@ -112,6 +133,44 @@ el.modal.classList.add("hidden"); } + function openViewModal(noteId) { + const note = state.notes.find((n) => String(n.id) === String(noteId)); + if (!note || !el.viewModal) return; + state.activeNoteId = String(note.id || ""); + if (el.viewTitle) { + el.viewTitle.textContent = String(note.title || "Заметка"); + } + if (el.viewContent) { + el.viewContent.textContent = String(note.content || ""); + } + el.viewModal.classList.remove("hidden"); + } + + function closeViewModal() { + if (!el.viewModal) return; + el.viewModal.classList.add("hidden"); + state.activeNoteId = ""; + } + + async function onDeleteActiveNote() { + const id = state.activeNoteId; + if (!id) return; + const ok = window.confirm("Удалить заметку?"); + if (!ok) return; + + setStatus("Удаляю заметку..."); + const res = await requestWithFallback( + getNoteEndpoints().map((base) => `${base}/${id}`), + [{ method: "DELETE" }] + ); + if (!res.success) { + setStatus(res.message); + return; + } + closeViewModal(); + await loadNotes(); + } + async function request(path, init = {}, forcedBase = "") { const common = window.AiCalCommon; const base = forcedBase || (common && common.getApiBaseUrl ? common.getApiBaseUrl() : window.location.origin); diff --git a/frontend/src/main/resources/static/profile.html b/frontend/src/main/resources/static/profile.html index 39fd86c..f260004 100644 --- a/frontend/src/main/resources/static/profile.html +++ b/frontend/src/main/resources/static/profile.html @@ -47,6 +47,14 @@

Impera Vox

Часовой пояс
-
+
+ + +
+
+
+
Календари
+
diff --git a/frontend/src/main/resources/static/profile.js b/frontend/src/main/resources/static/profile.js index c009057..f1a6851 100644 --- a/frontend/src/main/resources/static/profile.js +++ b/frontend/src/main/resources/static/profile.js @@ -1,11 +1,18 @@ (() => { const API_REQUEST_TIMEOUT_MS = 7000; + const CALENDAR_VISIBILITY_KEY = "impera_calendar_visibility"; const el = { status: document.getElementById("profileStatus"), username: document.getElementById("profileUsername"), prefix: document.getElementById("profilePrefix"), - timezone: document.getElementById("profileTimezone") + timezone: document.getElementById("profileTimezone"), + timezoneInput: document.getElementById("timezoneInput"), + timezoneSaveBtn: document.getElementById("timezoneSaveBtn"), + calendarSwitches: document.getElementById("calendarSwitches") + }; + const state = { + profile: null }; init(); @@ -15,6 +22,9 @@ if (common && typeof common.wakeUpServices === "function") { await common.wakeUpServices((msg) => setStatus(msg)); } + if (el.timezoneSaveBtn) { + el.timezoneSaveBtn.addEventListener("click", saveTimezone); + } await loadProfile(); } @@ -30,15 +40,95 @@ const user = data.username ? `@${data.username}` : "-"; el.username.textContent = user; } + state.profile = data; if (el.prefix) { el.prefix.textContent = data.titlePrefix || "-"; } if (el.timezone) { el.timezone.textContent = data.timezone || "-"; } + if (el.timezoneInput) { + el.timezoneInput.value = data.timezone || ""; + } + renderCalendarSwitches(data.calendars || {}); setStatus(""); } + function renderCalendarSwitches(calendars) { + if (!el.calendarSwitches) return; + const visibility = readCalendarVisibility(); + const rows = [ + { key: "internal", label: "Internal", sub: "Локальный календарь", connected: calendars.internal !== false }, + { key: "google", label: "Google", sub: calendars.google ? "Подключен" : "Не подключен", connected: !!calendars.google }, + { key: "ical", label: "iCal", sub: calendars.ical ? "Подключен" : "Не подключен", connected: !!calendars.ical } + ]; + el.calendarSwitches.innerHTML = ""; + for (const row of rows) { + const wrap = document.createElement("div"); + wrap.className = "profile-cal-row"; + const info = document.createElement("div"); + info.innerHTML = `
${row.label}
${row.sub}
`; + const toggle = document.createElement("button"); + toggle.type = "button"; + toggle.className = `toggle-switch${(visibility[row.key] ?? row.connected) ? " on" : ""}`; + toggle.disabled = !row.connected; + toggle.title = row.connected ? "Показать/скрыть" : "Источник не подключен"; + toggle.addEventListener("click", () => { + const next = !toggle.classList.contains("on"); + toggle.classList.toggle("on", next); + const current = readCalendarVisibility(); + current[row.key] = next; + saveCalendarVisibility(current); + setStatus("Настройки отображения календарей сохранены"); + }); + wrap.appendChild(info); + wrap.appendChild(toggle); + el.calendarSwitches.appendChild(wrap); + } + } + + async function saveTimezone() { + const value = String(el.timezoneInput && el.timezoneInput.value ? el.timezoneInput.value : "").trim(); + if (!value) { + setStatus("Укажи часовой пояс, например Europe/Moscow"); + return; + } + setStatus("Сохраняю часовой пояс..."); + const res = await requestWithFallback( + ["/api/miniapp/me/timezone"], + [{ method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ timezone: value }) }] + ); + if (!res.success) { + setStatus(res.message); + return; + } + if (el.timezone) { + el.timezone.textContent = value; + } + if (state.profile) { + state.profile.timezone = value; + } + setStatus("Часовой пояс обновлен"); + } + + function readCalendarVisibility() { + try { + const parsed = JSON.parse(localStorage.getItem(CALENDAR_VISIBILITY_KEY) || "{}"); + if (!parsed || typeof parsed !== "object") return {}; + return parsed; + } catch { + return {}; + } + } + + function saveCalendarVisibility(data) { + try { + localStorage.setItem(CALENDAR_VISIBILITY_KEY, JSON.stringify(data || {})); + } catch { + // ignore + } + } + async function request(path, init = {}, forcedBase = "") { const common = window.AiCalCommon; const base = forcedBase || (common && common.getApiBaseUrl ? common.getApiBaseUrl() : window.location.origin); diff --git a/frontend/src/main/resources/static/styles.css b/frontend/src/main/resources/static/styles.css index b75efc9..104ef80 100644 --- a/frontend/src/main/resources/static/styles.css +++ b/frontend/src/main/resources/static/styles.css @@ -496,9 +496,25 @@ body.sidebar-open .sidebar-backdrop { opacity: 0.55; } +.page-item.dragging-source { + opacity: 0.2; +} + +.task-touch-ghost { + position: fixed; + z-index: 120; + pointer-events: none; + opacity: 0.95; + box-shadow: 0 14px 32px rgba(2, 6, 23, 0.25); + transform: scale(1.02); +} + .page-item-title { font-size: 16px; font-weight: 600; + overflow-wrap: anywhere; + word-break: break-word; + max-width: 100%; } .page-item-meta { @@ -506,6 +522,36 @@ body.sidebar-open .sidebar-backdrop { color: var(--muted); font-size: 13px; white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; + max-width: 100%; +} + +.page-item.note-item { + cursor: pointer; +} + +.page-item.note-item .page-item-meta { + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.note-actions { + margin-top: 12px; + display: flex; + justify-content: flex-end; +} + +.note-delete-btn { + width: 34px; + height: 34px; + border: 1px solid #fecaca; + background: #fff1f2; + color: #be123c; + border-radius: 999px; + cursor: pointer; } .page-item-meta.due-urgent { @@ -770,6 +816,76 @@ body.sidebar-open .sidebar-backdrop { font-weight: 600; } +.profile-timezone-edit { + margin-top: 10px; + display: grid; + grid-template-columns: 1fr auto; + gap: 8px; +} + +.profile-timezone-edit input { + border: 1px solid var(--border); + border-radius: 10px; + padding: 10px 12px; + font: inherit; +} + +.profile-cal-list { + margin-top: 10px; + display: grid; + gap: 8px; +} + +.profile-cal-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + border: 1px solid var(--border); + border-radius: 10px; + padding: 8px 10px; +} + +.profile-cal-name { + font-size: 14px; + font-weight: 600; +} + +.profile-cal-sub { + font-size: 12px; + color: var(--muted); +} + +.toggle-switch { + position: relative; + width: 44px; + height: 24px; + border-radius: 999px; + background: #cbd5e1; + border: 0; + cursor: pointer; +} + +.toggle-switch.on { + background: #3b82f6; +} + +.toggle-switch::after { + content: ""; + position: absolute; + top: 3px; + left: 3px; + width: 18px; + height: 18px; + border-radius: 999px; + background: #fff; + transition: transform 0.15s ease; +} + +.toggle-switch.on::after { + transform: translateX(20px); +} + @media (max-width: 980px) { :root { --sidebar-width: 220px; diff --git a/frontend/src/main/resources/static/tasks.js b/frontend/src/main/resources/static/tasks.js index a8464a0..ae62708 100644 --- a/frontend/src/main/resources/static/tasks.js +++ b/frontend/src/main/resources/static/tasks.js @@ -2,6 +2,7 @@ const API_REQUEST_TIMEOUT_MS = 7000; const DELETE_DELAY_SECONDS = 5; const DAY_MS = 24 * 60 * 60 * 1000; + const IS_TOUCH_DEVICE = ("ontouchstart" in window) || (navigator.maxTouchPoints || 0) > 0; const el = { addBtn: document.getElementById("taskAddBtn"), @@ -19,7 +20,8 @@ const state = { tasks: [], activeTab: "active", - draggingTaskId: null + draggingTaskId: null, + touchDrag: null }; const pendingDelete = new Map(); @@ -151,16 +153,23 @@ for (const task of filtered) { const item = document.createElement("div"); item.className = "page-item"; - item.draggable = true; - item.addEventListener("dragstart", () => { - state.draggingTaskId = task.id; - item.classList.add("dragging"); - }); - item.addEventListener("dragend", () => { - state.draggingTaskId = null; - item.classList.remove("dragging"); - clearDropTargets(); - }); + item.draggable = !IS_TOUCH_DEVICE; + if (!IS_TOUCH_DEVICE) { + item.addEventListener("dragstart", () => { + state.draggingTaskId = task.id; + item.classList.add("dragging"); + }); + item.addEventListener("dragend", () => { + state.draggingTaskId = null; + item.classList.remove("dragging"); + clearDropTargets(); + }); + } else { + item.addEventListener("touchstart", (e) => onTouchDragStart(e, task.id, item), { passive: true }); + item.addEventListener("touchmove", onTouchDragMove, { passive: false }); + item.addEventListener("touchend", onTouchDragEnd, { passive: false }); + item.addEventListener("touchcancel", onTouchDragCancel, { passive: true }); + } const check = document.createElement("button"); check.type = "button"; @@ -364,6 +373,88 @@ } } + function onTouchDragStart(e, taskId, item) { + if (!e.touches || e.touches.length !== 1) return; + const target = e.target; + if (target instanceof HTMLElement && target.closest(".task-check")) { + return; + } + const touch = e.touches[0]; + const rect = item.getBoundingClientRect(); + const ghost = item.cloneNode(true); + ghost.classList.add("task-touch-ghost"); + ghost.style.width = `${rect.width}px`; + ghost.style.height = `${rect.height}px`; + ghost.style.left = `${rect.left}px`; + ghost.style.top = `${rect.top}px`; + document.body.appendChild(ghost); + + item.classList.add("dragging-source"); + state.touchDrag = { + taskId, + sourceEl: item, + ghostEl: ghost, + offsetX: touch.clientX - rect.left, + offsetY: touch.clientY - rect.top, + moved: false + }; + } + + function onTouchDragMove(e) { + if (!state.touchDrag || !e.touches || e.touches.length !== 1) return; + e.preventDefault(); + const touch = e.touches[0]; + const drag = state.touchDrag; + drag.moved = true; + drag.ghostEl.style.left = `${touch.clientX - drag.offsetX}px`; + drag.ghostEl.style.top = `${touch.clientY - drag.offsetY}px`; + + const tab = findTabByPoint(touch.clientX, touch.clientY); + clearDropTargets(); + if (tab) { + tab.classList.add("drop-target"); + } + } + + async function onTouchDragEnd(e) { + if (!state.touchDrag) return; + e.preventDefault(); + const changed = e.changedTouches && e.changedTouches[0] ? e.changedTouches[0] : null; + const drag = state.touchDrag; + const tab = changed ? findTabByPoint(changed.clientX, changed.clientY) : null; + const tabName = tab && tab.dataset ? tab.dataset.tab : ""; + + cleanupTouchDrag(); + + if (!tabName || !drag.taskId) return; + await moveTaskToTab(drag.taskId, tabName); + renderTasks(); + } + + function onTouchDragCancel() { + if (!state.touchDrag) return; + cleanupTouchDrag(); + } + + function cleanupTouchDrag() { + const drag = state.touchDrag; + if (!drag) return; + if (drag.ghostEl && drag.ghostEl.parentNode) { + drag.ghostEl.parentNode.removeChild(drag.ghostEl); + } + if (drag.sourceEl) { + drag.sourceEl.classList.remove("dragging-source"); + } + clearDropTargets(); + state.touchDrag = null; + } + + function findTabByPoint(clientX, clientY) { + const target = document.elementFromPoint(clientX, clientY); + if (!(target instanceof HTMLElement)) return null; + return target.closest(".task-tab"); + } + function isDueSoon(value) { if (!value) return false; const due = new Date(value); From 5715ab1e180f866621a692ade89a0b2d9ce29a19 Mon Sep 17 00:00:00 2001 From: gr1shan1a <368409@edu.itmo.ru> Date: Thu, 12 Mar 2026 16:44:58 +0300 Subject: [PATCH 15/15] <12.03.2026 16:44> server solution> --- .../controller/MiniAppMeetingController.java | 100 ++++++++++++++++-- .../java/com/aichef/domain/model/Meeting.java | 3 + .../repository/NotificationRepository.java | 7 ++ .../service/NotificationDispatchService.java | 22 +++- .../aichef/service/TelegramBotService.java | 17 ++- docker-compose.prod.yml | 69 ++++++++++++ frontend/src/main/resources/static/app.js | 50 +++------ frontend/src/main/resources/static/common.js | 35 +----- frontend/src/main/resources/static/config.js | 19 +--- frontend/src/main/resources/static/index.html | 13 +++ frontend/src/main/resources/static/styles.css | 4 +- 11 files changed, 238 insertions(+), 101 deletions(-) create mode 100644 docker-compose.prod.yml diff --git a/backend-core/src/main/java/com/aichef/controller/MiniAppMeetingController.java b/backend-core/src/main/java/com/aichef/controller/MiniAppMeetingController.java index f342aaa..ef1f646 100644 --- a/backend-core/src/main/java/com/aichef/controller/MiniAppMeetingController.java +++ b/backend-core/src/main/java/com/aichef/controller/MiniAppMeetingController.java @@ -1,22 +1,36 @@ package com.aichef.controller; import com.aichef.domain.enums.MeetingStatus; +import com.aichef.domain.enums.RelatedType; import com.aichef.domain.model.CalendarDay; import com.aichef.domain.model.Meeting; +import com.aichef.domain.model.Notification; import com.aichef.domain.model.User; import com.aichef.repository.CalendarDayRepository; import com.aichef.repository.MeetingRepository; +import com.aichef.repository.NotificationRepository; import com.aichef.service.GoogleCalendarService; import com.aichef.service.GoogleOAuthService; import com.aichef.service.MiniAppAuthService; import com.aichef.util.TextNormalization; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +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.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; -import org.springframework.web.bind.annotation.*; import java.time.LocalDate; import java.time.OffsetDateTime; @@ -33,10 +47,13 @@ public class MiniAppMeetingController { private static final Pattern HEX_COLOR_PATTERN = Pattern.compile("^#([0-9a-fA-F]{6})$"); private static final String DEFAULT_MEETING_COLOR = "#93c5fd"; + private static final int DEFAULT_REMINDER_MINUTES_BEFORE = 30; + private static final int MAX_REMINDER_MINUTES_BEFORE = 7 * 24 * 60; private final MiniAppAuthService miniAppAuthService; private final MeetingRepository meetingRepository; private final CalendarDayRepository calendarDayRepository; + private final NotificationRepository notificationRepository; private final GoogleCalendarService googleCalendarService; private final GoogleOAuthService googleOAuthService; @@ -84,10 +101,15 @@ public ResponseEntity create( if (!request.endsAt().isAfter(request.startsAt())) { return ResponseEntity.badRequest().body("endsAt must be after startsAt"); } + Integer reminderMinutesBefore = resolveReminderMinutes(request.reminderMinutesBefore(), null); + if (reminderMinutesBefore == null) { + return ResponseEntity.badRequest().body("Invalid reminderMinutesBefore"); + } String normalizedColor = normalizeHexColor(request.color()); if (request.color() != null && normalizedColor == null) { return ResponseEntity.badRequest().body("Invalid color"); } + User user = userOpt.get(); Meeting meeting = new Meeting(); meeting.setTitle(TextNormalization.normalizeRussian(request.title().trim())); @@ -96,6 +118,7 @@ public ResponseEntity create( meeting.setLocation(request.location()); meeting.setExternalLink(request.externalLink()); meeting.setColor(normalizedColor == null ? DEFAULT_MEETING_COLOR : normalizedColor); + meeting.setReminderMinutesBefore(reminderMinutesBefore); meeting.setStatus(MeetingStatus.CONFIRMED); meeting.setCalendarDay(getOrCreateDay(user, request.startsAt().toLocalDate())); try { @@ -110,7 +133,6 @@ public ResponseEntity create( return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Internal error"); } - // Create Google event only for users with explicit OAuth connection. if (googleOAuthService.isConnected(user)) { GoogleCalendarService.CreatedGoogleEvent googleEvent = googleCalendarService.createEvent( user, @@ -132,8 +154,9 @@ public ResponseEntity create( } } - log.info("MiniApp meeting created. userId={}, telegramId={}, meetingId={}, startsAt={}, endsAt={}, color={}", - user.getId(), user.getTelegramId(), meeting.getId(), meeting.getStartsAt(), meeting.getEndsAt(), meeting.getColor()); + rescheduleMeetingNotifications(user, meeting); + log.info("MiniApp meeting created. userId={}, telegramId={}, meetingId={}, startsAt={}, endsAt={}, color={}, reminderMinutesBefore={}", + user.getId(), user.getTelegramId(), meeting.getId(), meeting.getStartsAt(), meeting.getEndsAt(), meeting.getColor(), meeting.getReminderMinutesBefore()); return ResponseEntity.ok(MeetingDto.from(meeting)); } @@ -203,6 +226,16 @@ private ResponseEntity updateInternal( if (request.color() != null) { meeting.setColor(normalizedColor); } + if (request.reminderMinutesBefore() != null) { + Integer reminderMinutesBefore = resolveReminderMinutes(request.reminderMinutesBefore(), meeting.getReminderMinutesBefore()); + if (reminderMinutesBefore == null) { + return ResponseEntity.badRequest().body("Invalid reminderMinutesBefore"); + } + meeting.setReminderMinutesBefore(reminderMinutesBefore); + } else if (meeting.getReminderMinutesBefore() == null) { + meeting.setReminderMinutesBefore(DEFAULT_REMINDER_MINUTES_BEFORE); + } + OffsetDateTime startsAt = meeting.getStartsAt(); OffsetDateTime endsAt = meeting.getEndsAt(); if (startsAt == null || endsAt == null || !endsAt.isAfter(startsAt)) { @@ -266,8 +299,9 @@ private ResponseEntity updateInternal( } } - log.info("MiniApp meeting updated. userId={}, telegramId={}, meetingId={}, startsAt={}, endsAt={}, color={}", - user.getId(), user.getTelegramId(), meeting.getId(), meeting.getStartsAt(), meeting.getEndsAt(), meeting.getColor()); + rescheduleMeetingNotifications(user, meeting); + log.info("MiniApp meeting updated. userId={}, telegramId={}, meetingId={}, startsAt={}, endsAt={}, color={}, reminderMinutesBefore={}", + user.getId(), user.getTelegramId(), meeting.getId(), meeting.getStartsAt(), meeting.getEndsAt(), meeting.getColor(), meeting.getReminderMinutesBefore()); return ResponseEntity.ok(MeetingDto.from(meeting)); } @@ -289,6 +323,7 @@ public ResponseEntity delete( meeting.setStatus(MeetingStatus.CANCELED); try { meetingRepository.save(meeting); + notificationRepository.deleteByRelatedTypeAndRelatedId(RelatedType.MEETING, meeting.getId()); } catch (Exception e) { log.error("MiniApp meeting delete failed. userId={}, telegramId={}, meetingId={}, error={}", user.getId(), user.getTelegramId(), meeting.getId(), e.getMessage(), e); @@ -310,6 +345,48 @@ private CalendarDay getOrCreateDay(User user, LocalDate dayDate) { }); } + private void rescheduleMeetingNotifications(User user, Meeting meeting) { + if (user == null || meeting == null || meeting.getId() == null || meeting.getStartsAt() == null) { + return; + } + notificationRepository.deleteByRelatedTypeAndRelatedId(RelatedType.MEETING, meeting.getId()); + OffsetDateTime now = OffsetDateTime.now(); + OffsetDateTime startNotifyAt = meeting.getStartsAt().isAfter(now) + ? meeting.getStartsAt() + : now.plusSeconds(5); + scheduleMeetingNotification(user, meeting, startNotifyAt); + Integer reminderMinutesBefore = resolveReminderMinutes(meeting.getReminderMinutesBefore(), null); + if (reminderMinutesBefore != null && reminderMinutesBefore > 0) { + OffsetDateTime beforeNotifyAt = meeting.getStartsAt().minusMinutes(reminderMinutesBefore); + if (beforeNotifyAt.isAfter(now) && !beforeNotifyAt.isEqual(startNotifyAt)) { + scheduleMeetingNotification(user, meeting, beforeNotifyAt); + } + } + } + + private void scheduleMeetingNotification(User user, Meeting meeting, OffsetDateTime notifyAt) { + if (notifyAt == null) { + return; + } + Notification notification = new Notification(); + notification.setUser(user); + notification.setRelatedType(RelatedType.MEETING); + notification.setRelatedId(meeting.getId()); + notification.setNotifyAt(notifyAt); + notification.setSent(false); + notificationRepository.save(notification); + } + + private Integer resolveReminderMinutes(Integer value, Integer fallback) { + if (value == null) { + return fallback == null ? DEFAULT_REMINDER_MINUTES_BEFORE : fallback; + } + if (value < 0 || value > MAX_REMINDER_MINUTES_BEFORE) { + return null; + } + return value; + } + public record MeetingDto( UUID id, String title, @@ -318,7 +395,8 @@ public record MeetingDto( String location, String externalLink, String color, - String googleEventId + String googleEventId, + Integer reminderMinutesBefore ) { public static MeetingDto from(Meeting meeting) { return new MeetingDto( @@ -329,7 +407,8 @@ public static MeetingDto from(Meeting meeting) { TextNormalization.normalizeRussian(meeting.getLocation()), TextNormalization.normalizeRussian(meeting.getExternalLink()), TextNormalization.normalizeRussian(meeting.getColor()), - TextNormalization.normalizeRussian(meeting.getGoogleEventId()) + TextNormalization.normalizeRussian(meeting.getGoogleEventId()), + meeting.getReminderMinutesBefore() ); } } @@ -340,7 +419,8 @@ public record MeetingUpdateRequest( OffsetDateTime endsAt, String location, String externalLink, - String color + String color, + Integer reminderMinutesBefore ) { } diff --git a/backend-core/src/main/java/com/aichef/domain/model/Meeting.java b/backend-core/src/main/java/com/aichef/domain/model/Meeting.java index bf28b2c..97a841d 100644 --- a/backend-core/src/main/java/com/aichef/domain/model/Meeting.java +++ b/backend-core/src/main/java/com/aichef/domain/model/Meeting.java @@ -48,6 +48,9 @@ public class Meeting extends BaseEntity { @Column(name = "google_event_id") private String googleEventId; + @Column(name = "reminder_minutes_before") + private Integer reminderMinutesBefore; + @Enumerated(EnumType.STRING) @Column(nullable = false) private MeetingStatus status = MeetingStatus.CONFIRMED; diff --git a/backend-core/src/main/java/com/aichef/repository/NotificationRepository.java b/backend-core/src/main/java/com/aichef/repository/NotificationRepository.java index 234f989..de4a46b 100644 --- a/backend-core/src/main/java/com/aichef/repository/NotificationRepository.java +++ b/backend-core/src/main/java/com/aichef/repository/NotificationRepository.java @@ -1,7 +1,10 @@ package com.aichef.repository; import com.aichef.domain.model.Notification; +import com.aichef.domain.enums.RelatedType; +import jakarta.transaction.Transactional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import java.time.OffsetDateTime; import java.util.List; @@ -9,4 +12,8 @@ public interface NotificationRepository extends JpaRepository { List findTop100BySentFalseAndNotifyAtLessThanEqualOrderByNotifyAtAsc(OffsetDateTime now); + + @Modifying + @Transactional + void deleteByRelatedTypeAndRelatedId(RelatedType relatedType, UUID relatedId); } diff --git a/backend-core/src/main/java/com/aichef/service/NotificationDispatchService.java b/backend-core/src/main/java/com/aichef/service/NotificationDispatchService.java index a72fded..6e37163 100644 --- a/backend-core/src/main/java/com/aichef/service/NotificationDispatchService.java +++ b/backend-core/src/main/java/com/aichef/service/NotificationDispatchService.java @@ -14,6 +14,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.Duration; import java.time.OffsetDateTime; import java.time.ZoneId; import java.time.format.DateTimeFormatter; @@ -65,12 +66,17 @@ public void dispatchDueNotifications() { private String buildMessage(Notification notification) { if (notification.getRelatedType() == RelatedType.MEETING) { Meeting meeting = meetingRepository.findById(notification.getRelatedId()).orElse(null); - if (meeting == null) { + if (meeting == null || meeting.getStatus() == com.aichef.domain.enums.MeetingStatus.CANCELED) { return null; } ZoneId zoneId = resolveZone(notification.getUser()); String time = meeting.getStartsAt().atZoneSameInstant(zoneId).format(REMINDER_TIME_FMT); - return "⏰ Напоминание: через 30 минут событие \"" + meeting.getTitle() + "\".\n🕒 " + time; + long minutesBefore = Duration.between(notification.getNotifyAt(), meeting.getStartsAt()).toMinutes(); + if (minutesBefore <= 1) { + return "⏰ Напоминание: событие \"" + meeting.getTitle() + "\" начинается сейчас.\n🕒 " + time; + } + return "⏰ Напоминание: событие \"" + meeting.getTitle() + "\" начнется через " + + formatLeadTime(minutesBefore) + ".\n🕒 " + time; } if (notification.getRelatedType() == RelatedType.TASK) { @@ -84,6 +90,18 @@ private String buildMessage(Notification notification) { return null; } + private String formatLeadTime(long minutesBefore) { + if (minutesBefore < 60) { + return minutesBefore + " мин"; + } + long hours = minutesBefore / 60; + long mins = minutesBefore % 60; + if (mins == 0) { + return hours + " ч"; + } + return hours + " ч " + mins + " мин"; + } + private ZoneId resolveZone(User user) { if (user == null || user.getTimezone() == null || user.getTimezone().isBlank()) { return ZoneId.of("Europe/Moscow"); diff --git a/backend-core/src/main/java/com/aichef/service/TelegramBotService.java b/backend-core/src/main/java/com/aichef/service/TelegramBotService.java index 7c17899..8970c85 100644 --- a/backend-core/src/main/java/com/aichef/service/TelegramBotService.java +++ b/backend-core/src/main/java/com/aichef/service/TelegramBotService.java @@ -750,6 +750,7 @@ private Meeting createMeetingWithReminder( meeting.setEndsAt(endsAt); meeting.setExternalLink(externalLink); meeting.setColor("#93c5fd"); + meeting.setReminderMinutesBefore(30); meeting.setStatus(MeetingStatus.CONFIRMED); GoogleCalendarService.CreatedGoogleEvent googleEvent = googleCalendarService.createEvent( @@ -782,8 +783,20 @@ private void scheduleMeetingReminder(User user, Meeting meeting) { if (user == null || meeting == null || meeting.getStartsAt() == null || meeting.getId() == null) { return; } - OffsetDateTime notifyAt = meeting.getStartsAt().minusMinutes(30); - if (notifyAt.isBefore(OffsetDateTime.now())) { + OffsetDateTime now = OffsetDateTime.now(); + OffsetDateTime startNotifyAt = meeting.getStartsAt().isAfter(now) + ? meeting.getStartsAt() + : now.plusSeconds(5); + createMeetingNotification(user, meeting, startNotifyAt); + + OffsetDateTime beforeNotifyAt = meeting.getStartsAt().minusMinutes(30); + if (beforeNotifyAt.isAfter(now) && !beforeNotifyAt.isEqual(startNotifyAt)) { + createMeetingNotification(user, meeting, beforeNotifyAt); + } + } + + private void createMeetingNotification(User user, Meeting meeting, OffsetDateTime notifyAt) { + if (notifyAt == null) { return; } Notification notification = new Notification(); diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..eb66121 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,69 @@ +services: + postgres: + image: postgres:16 + container_name: aichef-postgres + restart: unless-stopped + environment: + POSTGRES_DB: aichef + POSTGRES_USER: aichef + POSTGRES_PASSWORD: aichef + ports: + - "5432:5432" + volumes: + - aichef_pg_data:/var/lib/postgresql/data + + miniapp: + container_name: aichef-miniapp + build: + context: . + dockerfile: miniapp-backend/Dockerfile.render + restart: unless-stopped + env_file: + - .env + environment: + SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/aichef + SPRING_DATASOURCE_USERNAME: aichef + SPRING_DATASOURCE_PASSWORD: aichef + MINIAPP_SERVER_PORT: 8010 + MINIAPP_PUBLIC_URL: ${MINIAPP_PUBLIC_URL:-http://185.192.246.155:5174/} + APP_PUBLIC_BASE_URL: ${APP_PUBLIC_BASE_URL:-http://185.192.246.155:8011} + depends_on: + - postgres + ports: + - "8010:8010" + + telegram: + container_name: aichef-telegram + build: + context: . + dockerfile: telegram-backend/Dockerfile.render + restart: unless-stopped + env_file: + - .env + environment: + SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/aichef + SPRING_DATASOURCE_USERNAME: aichef + SPRING_DATASOURCE_PASSWORD: aichef + TELEGRAM_SERVER_PORT: 8011 + APP_PUBLIC_BASE_URL: ${APP_PUBLIC_BASE_URL:-http://185.192.246.155:8011} + MINIAPP_PUBLIC_URL: ${MINIAPP_PUBLIC_URL:-http://185.192.246.155:5174/} + APP_VOSK_PYTHON: /opt/venv/bin/python + APP_VOSK_MODEL_PATH: /opt/models/vosk-model-small-ru-0.22 + depends_on: + - postgres + ports: + - "8011:8011" + + frontend: + container_name: aichef-frontend + build: + context: . + dockerfile: frontend/Dockerfile.render + restart: unless-stopped + environment: + FRONTEND_PORT: 5174 + ports: + - "5174:5174" + +volumes: + aichef_pg_data: diff --git a/frontend/src/main/resources/static/app.js b/frontend/src/main/resources/static/app.js index 22aa460..40f1865 100644 --- a/frontend/src/main/resources/static/app.js +++ b/frontend/src/main/resources/static/app.js @@ -45,6 +45,7 @@ eventEditDate: document.getElementById("eventEditDate"), eventEditTime: document.getElementById("eventEditTime"), eventEditDuration: document.getElementById("eventEditDuration"), + eventEditReminder: document.getElementById("eventEditReminder"), eventEditColor: document.getElementById("eventEditColor"), eventSubmitBtn: document.getElementById("eventSubmitBtn") }; @@ -489,6 +490,12 @@ const mins = isValidDate(startsAt) && isValidDate(endsAt) ? Math.max(5, Math.round((endsAt - startsAt) / 60000)) : 60; el.eventEditDuration.value = String(mins); } + if (el.eventEditReminder) { + const reminder = Number.isFinite(Number(meeting.reminderMinutesBefore)) + ? Number(meeting.reminderMinutesBefore) + : 30; + el.eventEditReminder.value = String(Math.max(0, reminder)); + } if (el.eventEditColor) { el.eventEditColor.value = normalizeHexColor(meeting.color) || "#93c5fd"; } @@ -517,11 +524,16 @@ const date = String(el.eventEditDate && el.eventEditDate.value ? el.eventEditDate.value : "").trim(); const time = String(el.eventEditTime && el.eventEditTime.value ? el.eventEditTime.value : "").trim(); const duration = Number(el.eventEditDuration && el.eventEditDuration.value ? el.eventEditDuration.value : 0); + const reminderMinutesBefore = Number(el.eventEditReminder && el.eventEditReminder.value ? el.eventEditReminder.value : 30); const color = normalizeHexColor(el.eventEditColor && el.eventEditColor.value ? el.eventEditColor.value : ""); if (!title || !date || !time || !Number.isFinite(duration) || duration <= 0) { setEventModalStatus("Проверьте название, дату, время и длительность"); return; } + if (!Number.isFinite(reminderMinutesBefore) || reminderMinutesBefore < 0) { + setEventModalStatus("Некорректное значение напоминания"); + return; + } if (!color) { setEventModalStatus("Некорректный цвет"); return; @@ -537,7 +549,8 @@ title, startsAt: toOffsetIso(startsAt), endsAt: toOffsetIso(endsAt), - color + color, + reminderMinutesBefore }; setEventModalStatus("Сохраняю..."); @@ -762,11 +775,7 @@ const host = window.location.hostname || ""; const base = getApiBaseUrl(); const explicitBase = readQueryApiBase() || readConfigApiBase() || readSavedApiBase(); - const inferredRenderBase = inferRenderMiniAppApiBase(); const list = [base]; - if (inferredRenderBase && inferredRenderBase !== base) { - list.push(inferredRenderBase); - } if (!explicitBase) { if (host) { @@ -787,9 +796,6 @@ if (origin && origin !== base) { list.push(origin); } - if (inferredRenderBase && inferredRenderBase !== origin) { - list.push(inferredRenderBase); - } } return Array.from(new Set(list.map((x) => String(x || "").trim().replace(/\/+$/, "")).filter(Boolean))); @@ -839,21 +845,6 @@ } } - function inferRenderMiniAppApiBase() { - const protocol = window.location.protocol || "https:"; - const hostname = window.location.hostname || ""; - if (!hostname || !hostname.endsWith(".onrender.com")) { - return ""; - } - if (hostname.includes("-frontend.")) { - return `${protocol}//${hostname.replace("-frontend.", "-miniapp-api.")}`; - } - if (hostname.includes("frontend")) { - return `${protocol}//${hostname.replace("frontend", "miniapp-api")}`; - } - return ""; - } - function saveTelegramId(value) { try { if (!value) return; @@ -899,18 +890,7 @@ } function getSiblingServiceOrigins() { - const protocol = window.location.protocol || "https:"; - const host = window.location.hostname || ""; - if (!host.endsWith(".onrender.com")) return []; - const set = new Set(); - if (host.includes("-frontend.")) { - set.add(`${protocol}//${host.replace("-frontend.", "-miniapp-api.")}`); - set.add(`${protocol}//${host.replace("-frontend.", "-telegram.")}`); - } else if (host.includes("frontend")) { - set.add(`${protocol}//${host.replace("frontend", "miniapp-api")}`); - set.add(`${protocol}//${host.replace("frontend", "telegram")}`); - } - return Array.from(set); + return []; } function fireWakePing(origin) { diff --git a/frontend/src/main/resources/static/common.js b/frontend/src/main/resources/static/common.js index a4a3a5e..485f38b 100644 --- a/frontend/src/main/resources/static/common.js +++ b/frontend/src/main/resources/static/common.js @@ -222,11 +222,7 @@ const host = window.location.hostname || ""; const base = getApiBaseUrl(); const explicitBase = readQueryApiBase() || readConfigApiBase() || readSavedApiBase(); - const inferredRenderBase = inferRenderMiniAppApiBase(); const list = [base]; - if (inferredRenderBase && inferredRenderBase !== base) { - list.push(inferredRenderBase); - } if (!explicitBase) { if (host) { @@ -247,9 +243,6 @@ if (origin && origin !== base) { list.push(origin); } - if (inferredRenderBase && inferredRenderBase !== origin) { - list.push(inferredRenderBase); - } } return Array.from(new Set(list.map((x) => String(x || "").trim().replace(/\/+$/, "")).filter(Boolean))); @@ -277,21 +270,6 @@ } } - function inferRenderMiniAppApiBase() { - const protocol = window.location.protocol || "https:"; - const hostname = window.location.hostname || ""; - if (!hostname || !hostname.endsWith(".onrender.com")) { - return ""; - } - if (hostname.includes("-frontend.")) { - return `${protocol}//${hostname.replace("-frontend.", "-miniapp-api.")}`; - } - if (hostname.includes("frontend")) { - return `${protocol}//${hostname.replace("frontend", "miniapp-api")}`; - } - return ""; - } - function saveTelegramId(value) { try { if (!value) return; @@ -349,18 +327,7 @@ } function getSiblingServiceOrigins() { - const protocol = window.location.protocol || "https:"; - const host = window.location.hostname || ""; - if (!host.endsWith(".onrender.com")) return []; - const set = new Set(); - if (host.includes("-frontend.")) { - set.add(`${protocol}//${host.replace("-frontend.", "-miniapp-api.")}`); - set.add(`${protocol}//${host.replace("-frontend.", "-telegram.")}`); - } else if (host.includes("frontend")) { - set.add(`${protocol}//${host.replace("frontend", "miniapp-api")}`); - set.add(`${protocol}//${host.replace("frontend", "telegram")}`); - } - return Array.from(set); + return []; } function fireWakePing(origin) { diff --git a/frontend/src/main/resources/static/config.js b/frontend/src/main/resources/static/config.js index 6c6b696..e57d5de 100644 --- a/frontend/src/main/resources/static/config.js +++ b/frontend/src/main/resources/static/config.js @@ -1,29 +1,14 @@ (() => { const host = String(window.location.hostname || "").toLowerCase(); const isLocalHost = host === "localhost" || host === "127.0.0.1" || host === "0.0.0.0"; - const renderApiBase = deriveRenderApiBase(window.location); + const sameOriginApiBase = String(window.location.origin || "").replace(/\/+$/, ""); window.__APP_CONFIG__ = { - apiBaseUrl: isLocalHost ? "http://localhost:8010" : renderApiBase, + apiBaseUrl: isLocalHost ? "http://localhost:8010" : sameOriginApiBase, endpoints: { meetings: ["/api/miniapp/meetings"], tasks: ["/api/miniapp/tasks"], notes: ["/api/miniapp/notes"] } }; - - function deriveRenderApiBase(loc) { - const protocol = String(loc && loc.protocol ? loc.protocol : "https:"); - const hostname = String(loc && loc.hostname ? loc.hostname : ""); - if (!hostname || !hostname.endsWith(".onrender.com")) { - return ""; - } - if (hostname.includes("-frontend.")) { - return `${protocol}//${hostname.replace("-frontend.", "-miniapp-api.")}`; - } - if (hostname.includes("frontend")) { - return `${protocol}//${hostname.replace("frontend", "miniapp-api")}`; - } - return ""; - } })(); diff --git a/frontend/src/main/resources/static/index.html b/frontend/src/main/resources/static/index.html index 905416a..a4ca2bb 100644 --- a/frontend/src/main/resources/static/index.html +++ b/frontend/src/main/resources/static/index.html @@ -81,6 +81,19 @@

Impera Vox

Длительность (мин) +