Implement external calendar integration and enhance API functionality
- Added support for fetching and parsing external ICS calendars, allowing events to be displayed on the duty grid. - Introduced a new API endpoint `/api/calendar-events` to retrieve calendar events within a specified date range. - Updated configuration to include `EXTERNAL_CALENDAR_ICS_URL` for specifying the ICS calendar URL. - Enhanced the web application to visually indicate days with events and provide event summaries on hover. - Improved documentation in the README to include details about the new calendar integration and configuration options. - Updated tests to cover the new calendar functionality and ensure proper integration.
This commit is contained in:
@@ -9,3 +9,6 @@ ADMIN_USERNAMES=admin1,admin2
|
||||
|
||||
# Dev only: set to 1 to allow calendar without Telegram initData (insecure; do not use in production).
|
||||
# MINI_APP_SKIP_AUTH=1
|
||||
|
||||
# Optional: URL of a public ICS calendar (e.g. holidays). Days from this calendar are highlighted on the duty grid; click "i" for summary.
|
||||
# EXTERNAL_CALENDAR_ICS_URL=https://example.com/holidays.ics
|
||||
|
||||
@@ -45,6 +45,7 @@ A minimal Telegram bot boilerplate using [python-telegram-bot](https://github.co
|
||||
- `MINI_APP_SKIP_AUTH` – Set to `1` to allow `/api/duties` without Telegram initData (dev only; insecure).
|
||||
- `INIT_DATA_MAX_AGE_SECONDS` – Reject Telegram initData older than this (e.g. `86400` = 24h). `0` = disabled (default).
|
||||
- `CORS_ORIGINS` – Comma-separated allowed origins for CORS, or leave unset for `*`.
|
||||
- `EXTERNAL_CALENDAR_ICS_URL` – URL of a public ICS calendar (e.g. holidays). If set, those days are highlighted on the duty grid; users can tap «i» on a cell to see the event summary. Empty = no external calendar.
|
||||
|
||||
## Run
|
||||
|
||||
|
||||
43
api/app.py
43
api/app.py
@@ -11,8 +11,9 @@ from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from db.session import session_scope
|
||||
from db.repository import get_duties
|
||||
from db.schemas import DutyWithUser
|
||||
from db.schemas import DutyWithUser, CalendarEvent
|
||||
from api.telegram_auth import validate_init_data_with_reason
|
||||
from api.calendar_ics import get_calendar_events
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -132,6 +133,46 @@ def list_duties(
|
||||
return _fetch_duties_response(from_date, to_date)
|
||||
|
||||
|
||||
def _require_same_auth(
|
||||
request: Request,
|
||||
x_telegram_init_data: str | None,
|
||||
) -> None:
|
||||
"""Raise HTTPException 403 if not allowed (same logic as list_duties)."""
|
||||
init_data = (x_telegram_init_data or "").strip()
|
||||
if not init_data:
|
||||
client_host = request.client.host if request.client else None
|
||||
if _is_private_client(client_host) or config.MINI_APP_SKIP_AUTH:
|
||||
return
|
||||
log.warning("calendar-events: no X-Telegram-Init-Data header (client=%s)", client_host)
|
||||
raise HTTPException(status_code=403, detail="Откройте календарь из Telegram")
|
||||
max_age = config.INIT_DATA_MAX_AGE_SECONDS or None
|
||||
username, auth_reason = validate_init_data_with_reason(
|
||||
init_data, config.BOT_TOKEN, max_age_seconds=max_age
|
||||
)
|
||||
if username is None:
|
||||
log.warning("calendar-events: initData validation failed: %s", auth_reason)
|
||||
raise HTTPException(status_code=403, detail=_auth_error_detail(auth_reason))
|
||||
if not config.can_access_miniapp(username):
|
||||
log.warning("calendar-events: username not in allowlist")
|
||||
raise HTTPException(status_code=403, detail="Доступ запрещён")
|
||||
|
||||
|
||||
@app.get("/api/calendar-events", response_model=list[CalendarEvent])
|
||||
def list_calendar_events(
|
||||
request: Request,
|
||||
from_date: str = Query(..., description="ISO date YYYY-MM-DD", alias="from"),
|
||||
to_date: str = Query(..., description="ISO date YYYY-MM-DD", alias="to"),
|
||||
x_telegram_init_data: str | None = Header(None, alias="X-Telegram-Init-Data"),
|
||||
) -> list[CalendarEvent]:
|
||||
_validate_duty_dates(from_date, to_date)
|
||||
_require_same_auth(request, x_telegram_init_data)
|
||||
url = config.EXTERNAL_CALENDAR_ICS_URL
|
||||
if not url:
|
||||
return []
|
||||
events = get_calendar_events(url, from_date=from_date, to_date=to_date)
|
||||
return [CalendarEvent(date=e["date"], summary=e["summary"]) for e in events]
|
||||
|
||||
|
||||
webapp_path = Path(__file__).resolve().parent.parent / "webapp"
|
||||
if webapp_path.is_dir():
|
||||
app.mount("/app", StaticFiles(directory=str(webapp_path), html=True), name="webapp")
|
||||
|
||||
124
api/calendar_ics.py
Normal file
124
api/calendar_ics.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""Fetch and parse external ICS calendar; in-memory cache with 7-day TTL."""
|
||||
|
||||
import logging
|
||||
from datetime import date, datetime, timedelta
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.error import URLError
|
||||
|
||||
from icalendar import Calendar
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# In-memory cache: url -> (cached_at_timestamp, raw_ics_bytes)
|
||||
_ics_cache: dict[str, tuple[float, bytes]] = {}
|
||||
CACHE_TTL_SECONDS = 7 * 24 * 3600 # 1 week
|
||||
FETCH_TIMEOUT_SECONDS = 15
|
||||
|
||||
|
||||
def _fetch_ics(url: str) -> bytes | None:
|
||||
"""GET url, return response body or None on error."""
|
||||
try:
|
||||
req = Request(url, headers={"User-Agent": "DutyTeller/1.0"})
|
||||
with urlopen(req, timeout=FETCH_TIMEOUT_SECONDS) as resp:
|
||||
return resp.read()
|
||||
except URLError as e:
|
||||
log.warning("Failed to fetch ICS from %s: %s", url, e)
|
||||
return None
|
||||
except OSError as e:
|
||||
log.warning("Error fetching ICS from %s: %s", url, e)
|
||||
return None
|
||||
|
||||
|
||||
def _to_date(dt) -> date | None:
|
||||
"""Convert icalendar DATE or DATE-TIME to date. Return None if invalid."""
|
||||
if isinstance(dt, datetime):
|
||||
return dt.date()
|
||||
if isinstance(dt, date):
|
||||
return dt
|
||||
return None
|
||||
|
||||
|
||||
def _event_date_range(component) -> tuple[date | None, date | None]:
|
||||
"""
|
||||
Get (start_date, end_date) for a VEVENT. DTEND is exclusive in iCalendar;
|
||||
last day of event = DTEND date - 1 day. Returns (None, None) if invalid.
|
||||
"""
|
||||
dtstart = component.get("dtstart")
|
||||
if not dtstart:
|
||||
return (None, None)
|
||||
start_d = _to_date(dtstart.dt)
|
||||
if not start_d:
|
||||
return (None, None)
|
||||
|
||||
dtend = component.get("dtend")
|
||||
if not dtend:
|
||||
return (start_d, start_d)
|
||||
|
||||
end_dt = dtend.dt
|
||||
end_d = _to_date(end_dt)
|
||||
if not end_d:
|
||||
return (start_d, start_d)
|
||||
# DTEND is exclusive: last day of event is end_d - 1 day
|
||||
last_d = end_d - timedelta(days=1)
|
||||
return (start_d, last_d)
|
||||
|
||||
|
||||
def _get_events_from_ics(raw: bytes, from_date: str, to_date: str) -> list[dict]:
|
||||
"""Parse ICS bytes and return list of {date, summary} in [from_date, to_date]. One-time events only."""
|
||||
result: list[dict] = []
|
||||
try:
|
||||
cal = Calendar.from_ical(raw)
|
||||
if not cal:
|
||||
return result
|
||||
except Exception as e:
|
||||
log.warning("Failed to parse ICS: %s", e)
|
||||
return result
|
||||
|
||||
from_d = date.fromisoformat(from_date)
|
||||
to_d = date.fromisoformat(to_date)
|
||||
|
||||
for component in cal.walk():
|
||||
if component.name != "VEVENT":
|
||||
continue
|
||||
if component.get("rrule"):
|
||||
continue # skip recurring in first iteration
|
||||
start_d, end_d = _event_date_range(component)
|
||||
if not start_d or not end_d:
|
||||
continue
|
||||
summary = component.get("summary")
|
||||
summary_str = str(summary) if summary else ""
|
||||
|
||||
d = start_d
|
||||
while d <= end_d:
|
||||
if from_d <= d <= to_d:
|
||||
result.append({"date": d.strftime("%Y-%m-%d"), "summary": summary_str})
|
||||
d += timedelta(days=1)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_calendar_events(
|
||||
url: str,
|
||||
from_date: str,
|
||||
to_date: str,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Return list of {date: "YYYY-MM-DD", summary: "..."} for events in [from_date, to_date].
|
||||
Uses in-memory cache with TTL 7 days. On fetch/parse error returns [].
|
||||
"""
|
||||
if not url or from_date > to_date:
|
||||
return []
|
||||
|
||||
now = datetime.now().timestamp()
|
||||
raw: bytes | None = None
|
||||
if url in _ics_cache:
|
||||
cached_at, cached_raw = _ics_cache[url]
|
||||
if now - cached_at < CACHE_TTL_SECONDS:
|
||||
raw = cached_raw
|
||||
if raw is None:
|
||||
raw = _fetch_ics(url)
|
||||
if raw is None:
|
||||
return []
|
||||
_ics_cache[url] = (now, raw)
|
||||
|
||||
return _get_events_from_ics(raw, from_date, to_date)
|
||||
@@ -41,6 +41,9 @@ CORS_ORIGINS = (
|
||||
else ["*"]
|
||||
)
|
||||
|
||||
# Optional: URL of a public ICS calendar (e.g. holidays). Empty = no external calendar; /api/calendar-events returns [].
|
||||
EXTERNAL_CALENDAR_ICS_URL = os.getenv("EXTERNAL_CALENDAR_ICS_URL", "").strip()
|
||||
|
||||
|
||||
def is_admin(username: str) -> bool:
|
||||
"""True if the given Telegram username (no @, any case) is in ADMIN_USERNAMES."""
|
||||
|
||||
@@ -32,7 +32,8 @@ class Duty(Base):
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id"), nullable=False
|
||||
)
|
||||
start_at: Mapped[str] = mapped_column(Text, nullable=False) # ISO 8601
|
||||
end_at: Mapped[str] = mapped_column(Text, nullable=False) # ISO 8601
|
||||
# UTC, ISO 8601 with Z suffix (e.g. 2025-01-15T09:00:00Z)
|
||||
start_at: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
end_at: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
user: Mapped["User"] = relationship("User", back_populates="duties")
|
||||
|
||||
@@ -40,7 +40,12 @@ def get_duties(
|
||||
from_date: str,
|
||||
to_date: str,
|
||||
) -> list[tuple[Duty, str]]:
|
||||
"""Return list of (Duty, full_name) overlapping the given date range (ISO date strings)."""
|
||||
"""Return list of (Duty, full_name) overlapping the given date range.
|
||||
|
||||
from_date/to_date are YYYY-MM-DD (first/last day of month in client's local calendar).
|
||||
Duty.start_at and end_at are stored in UTC (ISO 8601 with Z); lexicographic comparison
|
||||
with date strings yields correct overlap.
|
||||
"""
|
||||
q = (
|
||||
session.query(Duty, User.full_name)
|
||||
.join(User, Duty.user_id == User.id)
|
||||
@@ -55,6 +60,7 @@ def insert_duty(
|
||||
start_at: str,
|
||||
end_at: str,
|
||||
) -> Duty:
|
||||
"""Create a duty. start_at and end_at must be UTC, ISO 8601 with Z (e.g. 2025-01-15T09:00:00Z)."""
|
||||
duty = Duty(user_id=user_id, start_at=start_at, end_at=end_at)
|
||||
session.add(duty)
|
||||
session.commit()
|
||||
|
||||
@@ -23,8 +23,8 @@ class UserInDb(UserBase):
|
||||
|
||||
class DutyBase(BaseModel):
|
||||
user_id: int
|
||||
start_at: str # ISO 8601
|
||||
end_at: str # ISO 8601
|
||||
start_at: str # UTC, ISO 8601 with Z
|
||||
end_at: str # UTC, ISO 8601 with Z
|
||||
|
||||
|
||||
class DutyCreate(DutyBase):
|
||||
@@ -43,3 +43,10 @@ class DutyWithUser(DutyInDb):
|
||||
full_name: str
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class CalendarEvent(BaseModel):
|
||||
"""External calendar event (e.g. holiday) for a single day."""
|
||||
|
||||
date: str # YYYY-MM-DD
|
||||
summary: str
|
||||
|
||||
@@ -5,3 +5,4 @@ uvicorn[standard]>=0.32,<1.0
|
||||
sqlalchemy>=2.0,<3.0
|
||||
alembic>=1.14,<2.0
|
||||
pydantic>=2.0,<3.0
|
||||
icalendar>=5.0,<6.0
|
||||
|
||||
@@ -92,8 +92,8 @@ def test_duties_200_with_allowed_user(mock_can_access, mock_validate, client):
|
||||
{
|
||||
"id": 1,
|
||||
"user_id": 10,
|
||||
"start_at": "2025-01-15T09:00:00",
|
||||
"end_at": "2025-01-15T18:00:00",
|
||||
"start_at": "2025-01-15T09:00:00Z",
|
||||
"end_at": "2025-01-15T18:00:00Z",
|
||||
"full_name": "Иван Иванов",
|
||||
}
|
||||
]
|
||||
|
||||
142
webapp/app.js
142
webapp/app.js
@@ -20,8 +20,12 @@
|
||||
const prevBtn = document.getElementById("prevMonth");
|
||||
const nextBtn = document.getElementById("nextMonth");
|
||||
|
||||
function isoDate(d) {
|
||||
return d.toISOString().slice(0, 10);
|
||||
/** YYYY-MM-DD in local time (for calendar keys, "today", request range). */
|
||||
function localDateString(d) {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return y + "-" + m + "-" + day;
|
||||
}
|
||||
|
||||
function firstDayOfMonth(d) {
|
||||
@@ -112,16 +116,21 @@
|
||||
dutyListEl.hidden = false;
|
||||
}
|
||||
|
||||
function buildFetchOptions(initData) {
|
||||
var headers = {};
|
||||
if (initData) headers["X-Telegram-Init-Data"] = initData;
|
||||
var controller = new AbortController();
|
||||
var timeoutId = setTimeout(function () { controller.abort(); }, FETCH_TIMEOUT_MS);
|
||||
return { headers: headers, signal: controller.signal, timeoutId: timeoutId };
|
||||
}
|
||||
|
||||
async function fetchDuties(from, to) {
|
||||
const base = window.location.origin;
|
||||
const url = base + "/api/duties?from=" + encodeURIComponent(from) + "&to=" + encodeURIComponent(to);
|
||||
const initData = getInitData();
|
||||
const headers = {};
|
||||
if (initData) headers["X-Telegram-Init-Data"] = initData;
|
||||
var controller = new AbortController();
|
||||
var timeoutId = setTimeout(function () { controller.abort(); }, FETCH_TIMEOUT_MS);
|
||||
const opts = buildFetchOptions(initData);
|
||||
try {
|
||||
var res = await fetch(url, { headers: headers, signal: controller.signal });
|
||||
var res = await fetch(url, { headers: opts.headers, signal: opts.signal });
|
||||
if (res.status === 403) {
|
||||
var detail = "Доступ запрещён";
|
||||
try {
|
||||
@@ -142,35 +151,113 @@
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
clearTimeout(opts.timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
function renderCalendar(year, month, dutiesByDate) {
|
||||
/** Fetch calendar events for range. Returns [] on non-200 or error. Does not throw for 403 (caller uses duties 403). */
|
||||
async function fetchCalendarEvents(from, to) {
|
||||
const base = window.location.origin;
|
||||
const url = base + "/api/calendar-events?from=" + encodeURIComponent(from) + "&to=" + encodeURIComponent(to);
|
||||
const initData = getInitData();
|
||||
const opts = buildFetchOptions(initData);
|
||||
try {
|
||||
var res = await fetch(url, { headers: opts.headers, signal: opts.signal });
|
||||
if (!res.ok) return [];
|
||||
return res.json();
|
||||
} catch (e) {
|
||||
return [];
|
||||
} finally {
|
||||
clearTimeout(opts.timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Build { localDateKey -> array of summary strings }. API date is UTC YYYY-MM-DD; map to local date for calendar grid. */
|
||||
function calendarEventsByDate(events) {
|
||||
const byDate = {};
|
||||
(events || []).forEach(function (e) {
|
||||
var utcMidnight = new Date(e.date + "T00:00:00Z");
|
||||
var key = localDateString(utcMidnight);
|
||||
if (!byDate[key]) byDate[key] = [];
|
||||
if (e.summary) byDate[key].push(e.summary);
|
||||
});
|
||||
return byDate;
|
||||
}
|
||||
|
||||
function renderCalendar(year, month, dutiesByDate, calendarEventsByDate) {
|
||||
const first = firstDayOfMonth(new Date(year, month, 1));
|
||||
const last = lastDayOfMonth(new Date(year, month, 1));
|
||||
const start = getMonday(first);
|
||||
const today = isoDate(new Date());
|
||||
const today = localDateString(new Date());
|
||||
calendarEventsByDate = calendarEventsByDate || {};
|
||||
|
||||
calendarEl.innerHTML = "";
|
||||
let d = new Date(start);
|
||||
const cells = 42;
|
||||
for (let i = 0; i < cells; i++) {
|
||||
const key = isoDate(d);
|
||||
const key = localDateString(d);
|
||||
const isOther = d.getMonth() !== month;
|
||||
const dayDuties = dutiesByDate[key] || [];
|
||||
const isToday = key === today;
|
||||
const eventSummaries = calendarEventsByDate[key] || [];
|
||||
const hasEvent = eventSummaries.length > 0;
|
||||
|
||||
const cell = document.createElement("div");
|
||||
cell.className = "day" + (isOther ? " other-month" : "") + (isToday ? " today" : "") + (dayDuties.length ? " has-duty" : "");
|
||||
cell.className = "day" + (isOther ? " other-month" : "") + (isToday ? " today" : "") + (dayDuties.length ? " has-duty" : "") + (hasEvent ? " holiday" : "");
|
||||
cell.innerHTML =
|
||||
"<span class=\"num\">" + d.getDate() + "</span>" +
|
||||
(dayDuties.length ? "<span class=\"day-duties\">" + dayDuties.map(function (x) { return escapeHtml(x.full_name); }).join(", ") + "</span>" : "");
|
||||
(dayDuties.length ? "<span class=\"day-duties\">" + dayDuties.map(function (x) { return escapeHtml(x.full_name); }).join(", ") + "</span>" : "") +
|
||||
(hasEvent ? "<button type=\"button\" class=\"info-btn\" aria-label=\"Информация о дне\" data-summary=\"" + escapeHtml(eventSummaries.join("\n")) + "\">i</button>" : "");
|
||||
calendarEl.appendChild(cell);
|
||||
d.setDate(d.getDate() + 1);
|
||||
}
|
||||
|
||||
monthTitleEl.textContent = MONTHS[month] + " " + year;
|
||||
bindInfoButtonTooltips();
|
||||
}
|
||||
|
||||
function bindInfoButtonTooltips() {
|
||||
var hintEl = document.getElementById("calendarEventHint");
|
||||
if (!hintEl) {
|
||||
hintEl = document.createElement("div");
|
||||
hintEl.id = "calendarEventHint";
|
||||
hintEl.className = "calendar-event-hint";
|
||||
hintEl.setAttribute("role", "tooltip");
|
||||
hintEl.hidden = true;
|
||||
document.body.appendChild(hintEl);
|
||||
}
|
||||
calendarEl.querySelectorAll(".info-btn").forEach(function (btn) {
|
||||
btn.addEventListener("click", function (e) {
|
||||
e.stopPropagation();
|
||||
var summary = btn.getAttribute("data-summary") || "";
|
||||
if (hintEl.hidden || hintEl.textContent !== summary) {
|
||||
hintEl.textContent = summary;
|
||||
hintEl.hidden = false;
|
||||
var rect = btn.getBoundingClientRect();
|
||||
hintEl.style.left = (rect.left + window.scrollX) + "px";
|
||||
hintEl.style.top = (rect.top + window.scrollY - 4) + "px";
|
||||
hintEl.dataset.active = "1";
|
||||
} else {
|
||||
hintEl.hidden = true;
|
||||
hintEl.removeAttribute("data-active");
|
||||
}
|
||||
});
|
||||
});
|
||||
if (!document._calendarHintBound) {
|
||||
document._calendarHintBound = true;
|
||||
document.addEventListener("click", function () {
|
||||
if (hintEl.dataset.active) {
|
||||
hintEl.hidden = true;
|
||||
hintEl.removeAttribute("data-active");
|
||||
}
|
||||
});
|
||||
document.addEventListener("keydown", function (e) {
|
||||
if (e.key === "Escape" && hintEl.dataset.active) {
|
||||
hintEl.hidden = true;
|
||||
hintEl.removeAttribute("data-active");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renderDutyList(duties) {
|
||||
@@ -180,7 +267,7 @@
|
||||
}
|
||||
const grouped = {};
|
||||
duties.forEach(function (d) {
|
||||
const date = d.start_at.slice(0, 10);
|
||||
const date = localDateString(new Date(d.start_at));
|
||||
if (!grouped[date]) grouped[date] = [];
|
||||
grouped[date].push(d);
|
||||
});
|
||||
@@ -190,8 +277,10 @@
|
||||
const list = grouped[date];
|
||||
html += "<h2>" + date + "</h2>";
|
||||
list.forEach(function (d) {
|
||||
const start = d.start_at.slice(11, 16);
|
||||
const end = d.end_at.slice(11, 16);
|
||||
const startDate = new Date(d.start_at);
|
||||
const endDate = new Date(d.end_at);
|
||||
const start = String(startDate.getHours()).padStart(2, "0") + ":" + String(startDate.getMinutes()).padStart(2, "0");
|
||||
const end = String(endDate.getHours()).padStart(2, "0") + ":" + String(endDate.getMinutes()).padStart(2, "0");
|
||||
html += "<div class=\"duty-item\"><span class=\"name\">" + escapeHtml(d.full_name) + "</span><div class=\"time\">" + start + " – " + end + "</div></div>";
|
||||
});
|
||||
});
|
||||
@@ -204,15 +293,20 @@
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/** Group duties by local date (start_at/end_at are UTC). */
|
||||
function dutiesByDate(duties) {
|
||||
const byDate = {};
|
||||
duties.forEach(function (d) {
|
||||
const start = new Date(d.start_at);
|
||||
const end = new Date(d.end_at);
|
||||
for (let t = new Date(start); t <= end; t.setDate(t.getDate() + 1)) {
|
||||
const key = isoDate(t);
|
||||
const endLocal = localDateString(end);
|
||||
let t = new Date(start);
|
||||
while (true) {
|
||||
const key = localDateString(t);
|
||||
if (!byDate[key]) byDate[key] = [];
|
||||
byDate[key].push(d);
|
||||
if (key === endLocal) break;
|
||||
t.setDate(t.getDate() + 1);
|
||||
}
|
||||
});
|
||||
return byDate;
|
||||
@@ -267,12 +361,16 @@
|
||||
setNavEnabled(false);
|
||||
loadingEl.classList.remove("hidden");
|
||||
errorEl.hidden = true;
|
||||
const from = isoDate(firstDayOfMonth(current));
|
||||
const to = isoDate(lastDayOfMonth(current));
|
||||
const from = localDateString(firstDayOfMonth(current));
|
||||
const to = localDateString(lastDayOfMonth(current));
|
||||
try {
|
||||
const duties = await fetchDuties(from, to);
|
||||
const dutiesPromise = fetchDuties(from, to);
|
||||
const eventsPromise = fetchCalendarEvents(from, to);
|
||||
const duties = await dutiesPromise;
|
||||
const events = await eventsPromise;
|
||||
const byDate = dutiesByDate(duties);
|
||||
renderCalendar(current.getFullYear(), current.getMonth(), byDate);
|
||||
const calendarByDate = calendarEventsByDate(events);
|
||||
renderCalendar(current.getFullYear(), current.getMonth(), byDate, calendarByDate);
|
||||
renderDutyList(duties);
|
||||
} catch (e) {
|
||||
if (e.message === "ACCESS_DENIED") {
|
||||
|
||||
@@ -105,6 +105,57 @@ body {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.day.holiday {
|
||||
background: linear-gradient(135deg, var(--surface) 0%, rgba(187, 154, 247, 0.15) 100%);
|
||||
border: 1px solid rgba(187, 154, 247, 0.35);
|
||||
}
|
||||
|
||||
.day {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.info-btn {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: var(--accent);
|
||||
color: var(--bg);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-end;
|
||||
flex-shrink: 0;
|
||||
clip-path: path("M 0 0 L 14 0 Q 22 0 22 8 L 22 22 Z");
|
||||
padding: 2px 3px 0 0;
|
||||
}
|
||||
|
||||
.info-btn:active {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.calendar-event-hint {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
max-width: 280px;
|
||||
padding: 8px 12px;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.4;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
|
||||
.day-duties {
|
||||
font-size: 0.6rem;
|
||||
color: var(--duty);
|
||||
|
||||
Reference in New Issue
Block a user