diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0b83c72 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +__pycache__/ +.pytest_cache/ +.venv/ +.git/ +elastisched.db +frontend/node_modules/ +offline-packages/ +docs/ +tests/ +learning/ +mcp/ diff --git a/.gitignore b/.gitignore index 68ba95c..a1904e9 100644 --- a/.gitignore +++ b/.gitignore @@ -5,8 +5,17 @@ **/.vscode **/build **/logs +**/node_modules +**/dist +**/.parcel-cache +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* src/elastisched/engine/bin offline-packages -elastisched.db \ No newline at end of file +elastisched.db +elastischedcopy.db \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..858c6c4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PYTHONPATH=/app/backend + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential cmake \ + && rm -rf /var/lib/apt/lists/* + +RUN mkdir -p /app/data + +COPY README.md requirements.txt pyproject.toml /app/ +COPY core /app/core +COPY engine /app/engine +COPY backend /app/backend +COPY frontend /app/frontend + +RUN pip install --upgrade pip \ + && pip install -r requirements.txt \ + && pip install -e . + +EXPOSE 8000 + +CMD ["uvicorn", "elastisched_api.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md index d28fec6..b792703 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,13 @@ # Elastisched ![Elastisched UI](docs/ui-preview.png) -Elastisched is a scheduling playground built around "blobs": flexible, schedulable blocks of time with constraints. The project includes a FastAPI backend and a lightweight UI for exploring day/week/month/year views. - - +Elastisched is a scheduling and time management software built around "blobs": flexible, schedulable blocks of time with constraints. ## Core Concepts -- Blobs represent tasks/events with a schedulable time range and optional policies. +- Blobs represent tasks/events with a schedulable time range and optional policies (which guide the scheduler). - Policies can make blobs overlappable, invisible, or splittable to guide scheduling behavior. -- Scheduling uses simulated annealing to search for good local minima under constraints. +- Scheduling uses simulated annealing and preference learning to search for good local minima under constraints. ## Features @@ -20,12 +18,24 @@ Elastisched is a scheduling playground built around "blobs": flexible, schedulab ## Project Layout -- `src/elastisched/api`: FastAPI backend and models +- `backend/elastisched_api`: FastAPI backend and models +- `core`: core scheduling library +- `engine`: C++ scheduler engine - `frontend`: UI (HTML/CSS/JS) +- `learning`: preference learning and ML models +- `integrations`: integrations with other calendar apps, i.e., Google Calendar +- `mcp`: a FastMCP integration for elastisched - `tests`: API and recurrence tests ## Quick Start +### Docker + +1. `docker compose up --build` +2. Open `http://localhost:8000/ui` + +### Local (no Docker) + 1. Install dependencies. 2. Run the API. 3. Open the UI at `/ui` or open `frontend/index.html` directly. @@ -33,3 +43,35 @@ Elastisched is a scheduling playground built around "blobs": flexible, schedulab ## Troubleshooting - If you see a GLIB/GCC mismatch in native builds, align your Conda GCC/G++ with the version used to compile the library. + +## Miscellaneous + +### Scheduler Algorithm +The scheduler algorithm uses simulated annealing, which is a form of stochastic optimization for often discrete, non-differentiable cost functions. +It will not always find the global minima, but tends to find good local minima. However, for calendars with not as many events, it will find the global +minima with high probability. + +#### Lookahead +Lookahead is an important concept since it determines how many events the scheduler will have to work with at a time, affecting efficiency and solution +quality. The default is set to 2 weeks, but can be changed in settings. Whenever a blob or recurrence is added or edited, the scheduler marks the old schedule +as "dirty", prompting the user to run the scheduling algorithm. + +#### Preference Learning +Apart from some the most primitive cost functions, the simulated annealer won't be able to truly capture a user's intent only through code, since +our preferences for what a good schedule might look like depends on the user and can't simply be hard coded in. Hence, elastisched also implements +preference learning. The most primitive cost functions currently implemented are: illegal schedule cost (which may be deprecated to prevent illegal +schedules entirely via simulated annealer), overlapping event cost (reduce the amount of overlap for overlappable events), and split count cost +(reduce the amount of times that we split a splittable event). Naturally, most schedules conditioned by the simulated annealer are "optimal", which +implies there are many potential global optima and requires tie-breaking. + +Preference learning helps to break ties by slowly learning the user's preferences over what schedules should be chosen when multiple global optima +exists. + +We can view the simulated annealer as a generative algorithm which generates optimal schedules. Preference learning then runs the learned cost function +against multiple samples generated by the annealer and tries to learn the following distribution: P(U(A) > U(B)), where A and B are schedules returned by the +annealer and U is some utility function implicitly learned by the preference learning model. We can then use this learned preference function to return +the most suitable schedule in O(S) time, where S is the number of samples discovered by the simulated annealer. + +#### Semantic Preference Learning +To help the preference learning algorithm, we utilize all information about an event including the description and recurrence name. +In the backend, we use a sentence-transformer model which can be optionally GPU accelerated if the user has a GPU. diff --git a/backend/elastisched_api/__init__.py b/backend/elastisched_api/__init__.py new file mode 100644 index 0000000..3331cc7 --- /dev/null +++ b/backend/elastisched_api/__init__.py @@ -0,0 +1,3 @@ +from elastisched_api.main import app + +__all__ = ["app"] diff --git a/src/elastisched/api/config.py b/backend/elastisched_api/config.py similarity index 62% rename from src/elastisched/api/config.py rename to backend/elastisched_api/config.py index 2347b95..c276cc9 100644 --- a/src/elastisched/api/config.py +++ b/backend/elastisched_api/config.py @@ -1,7 +1,7 @@ import os -DEFAULT_DATABASE_URL = "sqlite+aiosqlite:///./elastisched.db" +DEFAULT_DATABASE_URL = "sqlite+aiosqlite:///./core.db" def get_database_url() -> str: diff --git a/src/elastisched/api/db.py b/backend/elastisched_api/db.py similarity index 63% rename from src/elastisched/api/db.py rename to backend/elastisched_api/db.py index edf168c..7978e43 100644 --- a/src/elastisched/api/db.py +++ b/backend/elastisched_api/db.py @@ -4,7 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.orm import DeclarativeBase -from elastisched.api.config import get_database_url +from elastisched_api.config import get_database_url DATABASE_URL = get_database_url() @@ -23,10 +23,13 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]: async def init_db() -> None: + # Ensure model metadata is registered on the current Base. + import elastisched_api.models as models # noqa: F401 async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) if conn.dialect.name == "sqlite": await _ensure_sqlite_blob_columns(conn) + await _ensure_sqlite_scheduled_occurrence_columns(conn) async def _ensure_sqlite_blob_columns(conn) -> None: @@ -39,3 +42,15 @@ async def _ensure_sqlite_blob_columns(conn) -> None: missing.append(("realized_end", "DATETIME")) for name, col_type in missing: await conn.execute(text(f"ALTER TABLE blobs ADD COLUMN {name} {col_type}")) + + +async def _ensure_sqlite_scheduled_occurrence_columns(conn) -> None: + result = await conn.execute(text("PRAGMA table_info(scheduled_occurrences)")) + columns = {row[1] for row in result.fetchall()} + missing = [] + if "segment_index" not in columns: + missing.append(("segment_index", "INTEGER DEFAULT 0")) + for name, col_type in missing: + await conn.execute( + text(f"ALTER TABLE scheduled_occurrences ADD COLUMN {name} {col_type}") + ) diff --git a/src/elastisched/api/main.py b/backend/elastisched_api/main.py similarity index 69% rename from src/elastisched/api/main.py rename to backend/elastisched_api/main.py index 4d126d0..06441ba 100644 --- a/src/elastisched/api/main.py +++ b/backend/elastisched_api/main.py @@ -3,17 +3,17 @@ from fastapi import FastAPI from fastapi.staticfiles import StaticFiles -from elastisched.api.db import init_db -from elastisched.api.router import router as blob_router -from elastisched.api.recurrence_router import ( +from elastisched_api.db import init_db +from elastisched_api.router import router as blob_router +from elastisched_api.recurrence_router import ( occurrence_router, recurrence_router, ) -from elastisched.api.schedule_router import schedule_router +from elastisched_api.schedule_router import schedule_router app = FastAPI(title="Elastisched API") -_UI_DIR = Path(__file__).resolve().parents[3] / "frontend" +_UI_DIR = Path(__file__).resolve().parents[2] / "frontend" if _UI_DIR.exists(): app.mount("/ui", StaticFiles(directory=_UI_DIR, html=True), name="ui") diff --git a/src/elastisched/api/models.py b/backend/elastisched_api/models.py similarity index 94% rename from src/elastisched/api/models.py rename to backend/elastisched_api/models.py index 51c8b38..2ad3ebc 100644 --- a/src/elastisched/api/models.py +++ b/backend/elastisched_api/models.py @@ -3,7 +3,7 @@ from sqlalchemy import Boolean, DateTime, Integer, JSON, String from sqlalchemy.orm import Mapped, mapped_column -from elastisched.api.db import Base +from elastisched_api.db import Base class BlobModel(Base): @@ -36,6 +36,7 @@ class ScheduledOccurrenceModel(Base): __tablename__ = "scheduled_occurrences" id: Mapped[str] = mapped_column(String(200), primary_key=True) + segment_index: Mapped[int] = mapped_column(Integer, primary_key=True, default=0) realized_start: Mapped[datetime] = mapped_column(DateTime(timezone=True)) realized_end: Mapped[datetime] = mapped_column(DateTime(timezone=True)) diff --git a/src/elastisched/api/recurrence_router.py b/backend/elastisched_api/recurrence_router.py similarity index 59% rename from src/elastisched/api/recurrence_router.py rename to backend/elastisched_api/recurrence_router.py index 9ae4acf..b843ce8 100644 --- a/src/elastisched/api/recurrence_router.py +++ b/backend/elastisched_api/recurrence_router.py @@ -5,28 +5,30 @@ from sqlalchemy import delete, select from sqlalchemy.ext.asyncio import AsyncSession -from elastisched.api.db import get_session -from elastisched.api.models import ( +from elastisched_api.db import get_session +from elastisched_api.models import ( RecurrenceModel, ScheduledOccurrenceModel, ScheduleStateModel, ) -from elastisched.api.schemas import ( +from elastisched_api.schemas import ( OccurrenceRead, RecurrenceCreate, RecurrenceRead, RecurrenceUpdate, TimeRangeSchema, ) -from elastisched.blob import Blob -from elastisched.recurrence import ( +from core.blob import Blob +from core.recurrence import ( DateBlobRecurrence, DeltaBlobRecurrence, + MultipleBlobOccurrence, SingleBlobOccurrence, WeeklyBlobRecurrence, ) -from elastisched.timerange import TimeRange -from elastisched.constants import DEFAULT_TZ +from core.timerange import TimeRange +from core.constants import DEFAULT_TZ +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from engine import Tag @@ -46,6 +48,19 @@ async def _mark_schedule_dirty(session: AsyncSession) -> None: await session.commit() +def _normalize_recurrence_type(value: str | None) -> str: + raw = (value or "").strip().lower() + raw = raw.replace("-", "_").replace(" ", "_") + aliases = { + "single_occurrence": "single", + "weekly_cadence": "weekly", + "fixed_interval": "delta", + "annual_date": "date", + "multiple_occurrence": "multiple", + } + return aliases.get(raw, raw) + + def _parse_datetime(value: str) -> datetime: if isinstance(value, datetime): return value @@ -80,6 +95,15 @@ def _parse_timerange(data: dict, tzinfo) -> TimeRange: return TimeRange(start=start, end=end) +def _resolve_payload_tz(value: str | None): + if not value: + return DEFAULT_TZ + try: + return ZoneInfo(value) + except ZoneInfoNotFoundError: + return DEFAULT_TZ + + def _serialize_tags(raw_tags) -> list[str]: tags = [] for tag in raw_tags or []: @@ -100,8 +124,23 @@ def _serialize_tags(raw_tags) -> list[str]: return tags +def _payload_end_datetime(payload: dict, tzinfo) -> datetime | None: + if not payload: + return None + raw = payload.get("end_date") + if not raw: + return None + end_date = _parse_datetime(raw) + if tzinfo: + if end_date.tzinfo is None: + end_date = end_date.replace(tzinfo=tzinfo) + else: + end_date = end_date.astimezone(tzinfo) + return end_date + + def _blob_from_payload(data: dict) -> Blob: - tzinfo = DEFAULT_TZ + tzinfo = _resolve_payload_tz(data.get("tz")) default_tr = _parse_timerange(data.get("default_scheduled_timerange", {}), tzinfo) schedulable_tr = _parse_timerange(data.get("schedulable_timerange", {}), tzinfo) return Blob( @@ -117,7 +156,10 @@ def _blob_from_payload(data: dict) -> Blob: def _recurrence_from_payload(recurrence_type: str, payload: dict): + recurrence_type = _normalize_recurrence_type(recurrence_type) payload = payload or {} + if payload.get("end_date"): + _parse_datetime(payload.get("end_date")) if recurrence_type == "single": blob = _blob_from_payload(payload.get("blob") or {}) return SingleBlobOccurrence(blob=blob) @@ -144,7 +186,41 @@ def _recurrence_from_payload(recurrence_type: str, payload: dict): ) if recurrence_type == "date": blob = _blob_from_payload(payload.get("blob") or {}) + default_tr = blob.get_default_scheduled_timerange() + tzinfo = blob.tz or default_tr.start.tzinfo + start_local = default_tr.start.astimezone(tzinfo) if tzinfo else default_tr.start + day_start = datetime( + year=start_local.year, + month=start_local.month, + day=start_local.day, + hour=0, + minute=0, + second=0, + tzinfo=tzinfo, + ) + day_end = datetime( + year=start_local.year, + month=start_local.month, + day=start_local.day, + hour=23, + minute=59, + second=59, + tzinfo=tzinfo, + ) + if default_tr.start.tzinfo: + day_start = day_start.astimezone(default_tr.start.tzinfo) + day_end = day_end.astimezone(default_tr.start.tzinfo) + blob.set_default_scheduled_timerange(TimeRange(start=day_start, end=day_end)) + blob.set_schedulable_timerange(TimeRange(start=day_start, end=day_end)) return DateBlobRecurrence(blob=blob) + if recurrence_type == "multiple": + blobs = [_blob_from_payload(blob) for blob in payload.get("blobs") or []] + if not blobs: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Multiple occurrence requires blobs", + ) + return MultipleBlobOccurrence(blobs=blobs) raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Unsupported recurrence type", @@ -166,6 +242,8 @@ def _recurrence_tzinfo(recurrence_obj): return recurrence_obj.blob.get_schedulable_timerange().start.tzinfo if isinstance(recurrence_obj, SingleBlobOccurrence): return recurrence_obj.blob.get_schedulable_timerange().start.tzinfo + if isinstance(recurrence_obj, MultipleBlobOccurrence): + return recurrence_obj.blobs[0].get_schedulable_timerange().start.tzinfo return None @@ -204,6 +282,32 @@ def _to_occurrence_schema( ) -> OccurrenceRead: default_tr = blob.get_default_scheduled_timerange() schedulable_tr = blob.get_schedulable_timerange() + overrides = payload.get("occurrence_overrides") if isinstance(payload, dict) else None + if isinstance(overrides, dict): + occurrence_start = schedulable_tr.start + occurrence_ts = int(occurrence_start.timestamp()) + override = None + for key, value in overrides.items(): + if not isinstance(value, dict): + continue + try: + key_dt = _parse_datetime(key) + except HTTPException: + continue + if key_dt.tzinfo is None: + key_dt = key_dt.replace(tzinfo=occurrence_start.tzinfo) + else: + key_dt = key_dt.astimezone(occurrence_start.tzinfo) + if int(key_dt.timestamp()) == occurrence_ts: + override = value + break + if override: + tzinfo = blob.tz or occurrence_start.tzinfo + sched_override = override.get("schedulable_timerange") + if isinstance(sched_override, dict): + candidate = _parse_timerange(sched_override, tzinfo) + if candidate.start < candidate.end: + schedulable_tr = candidate return OccurrenceRead( id=_occurrence_id(recurrence_id, blob), recurrence_id=recurrence_id, @@ -225,14 +329,41 @@ def _to_occurrence_schema( ) -@recurrence_router.post("", response_model=RecurrenceRead, status_code=status.HTTP_201_CREATED) +@recurrence_router.post( + "", + response_model=RecurrenceRead | list[RecurrenceRead], + status_code=status.HTTP_201_CREATED, +) async def create_recurrence( - payload: RecurrenceCreate, session: AsyncSession = Depends(get_session) -) -> RecurrenceRead: - _recurrence_from_payload(payload.type, payload.payload) + payload: RecurrenceCreate | list[RecurrenceCreate], + session: AsyncSession = Depends(get_session), +) -> RecurrenceRead | list[RecurrenceRead]: + if isinstance(payload, list): + if not payload: + return [] + recurrences = [] + for item in payload: + normalized_type = _normalize_recurrence_type(item.type) + _recurrence_from_payload(normalized_type, item.payload) + recurrences.append( + RecurrenceModel( + id=str(uuid.uuid4()), + type=normalized_type, + payload=item.payload, + ) + ) + session.add_all(recurrences) + await session.commit() + await _mark_schedule_dirty(session) + return [ + RecurrenceRead(id=item.id, type=item.type, payload=item.payload) + for item in recurrences + ] + normalized_type = _normalize_recurrence_type(payload.type) + _recurrence_from_payload(normalized_type, payload.payload) recurrence = RecurrenceModel( id=str(uuid.uuid4()), - type=payload.type, + type=normalized_type, payload=payload.payload, ) session.add(recurrence) @@ -242,6 +373,32 @@ async def create_recurrence( return RecurrenceRead(id=recurrence.id, type=recurrence.type, payload=recurrence.payload) +@recurrence_router.post("/bulk", response_model=list[RecurrenceRead]) +async def create_recurrences_bulk( + payload: list[RecurrenceCreate], session: AsyncSession = Depends(get_session) +) -> list[RecurrenceRead]: + if not payload: + return [] + recurrences = [] + for item in payload: + normalized_type = _normalize_recurrence_type(item.type) + _recurrence_from_payload(normalized_type, item.payload) + recurrences.append( + RecurrenceModel( + id=str(uuid.uuid4()), + type=normalized_type, + payload=item.payload, + ) + ) + session.add_all(recurrences) + await session.commit() + await _mark_schedule_dirty(session) + return [ + RecurrenceRead(id=item.id, type=item.type, payload=item.payload) + for item in recurrences + ] + + @recurrence_router.get("", response_model=list[RecurrenceRead]) async def list_recurrences( session: AsyncSession = Depends(get_session), @@ -277,7 +434,7 @@ async def update_recurrence( if not recurrence: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Recurrence not found") - new_type = payload.type or recurrence.type + new_type = _normalize_recurrence_type(payload.type or recurrence.type) new_payload = payload.payload if payload.payload is not None else recurrence.payload _recurrence_from_payload(new_type, new_payload) recurrence.type = new_type @@ -319,19 +476,27 @@ async def list_occurrences( result = await session.execute(select(RecurrenceModel)) occurrences: list[OccurrenceRead] = [] for recurrence in result.scalars().all(): + recurrence_type = _normalize_recurrence_type(recurrence.type) exclusions = _exclusion_set(recurrence.payload or {}) - recurrence_obj = _recurrence_from_payload(recurrence.type, recurrence.payload) + recurrence_obj = _recurrence_from_payload(recurrence_type, recurrence.payload) recurrence_tz = _recurrence_tzinfo(recurrence_obj) recurrence_range = _coerce_timerange(timerange, recurrence_tz) + end_date = _payload_end_datetime(recurrence.payload or {}, recurrence_tz) + if end_date and end_date < recurrence_range.start: + continue + if end_date and end_date < recurrence_range.end: + recurrence_range = TimeRange(start=recurrence_range.start, end=end_date) for blob in recurrence_obj.all_occurrences(recurrence_range): start = blob.get_schedulable_timerange().start + if end_date and start > end_date: + continue if start.tzinfo is None: start = start.replace(tzinfo=timezone.utc) if int(start.timestamp()) in exclusions: continue occurrences.append( _to_occurrence_schema( - recurrence.id, recurrence.type, recurrence.payload, blob + recurrence.id, recurrence_type, recurrence.payload, blob ) ) if occurrences: @@ -341,31 +506,42 @@ async def list_occurrences( ScheduledOccurrenceModel.id.in_(occurrence_ids) ) ) - scheduled = { - item.id: item for item in scheduled_result.scalars().all() - } + scheduled: dict[str, list[ScheduledOccurrenceModel]] = {} + for item in scheduled_result.scalars().all(): + scheduled.setdefault(item.id, []).append(item) if scheduled: def _coerce_project(value: datetime) -> datetime: if value.tzinfo is None: return value.replace(tzinfo=DEFAULT_TZ) return value.astimezone(DEFAULT_TZ) - occurrences = [ - occurrence.model_copy( - update={ - "realized_timerange": TimeRangeSchema( - start=_coerce_project(scheduled[occurrence.id].realized_start), - end=_coerce_project(scheduled[occurrence.id].realized_end), + expanded = [] + for occurrence in occurrences: + rows = scheduled.get(occurrence.id) + if not rows: + expanded.append(occurrence) + continue + for row in rows: + realized_start = _coerce_project(row.realized_start) + realized_end = _coerce_project(row.realized_end) + if ( + _coerce_project(occurrence.schedulable_timerange.start) + <= realized_start + <= realized_end + <= _coerce_project(occurrence.schedulable_timerange.end) + ): + expanded.append( + occurrence.model_copy( + update={ + "realized_timerange": TimeRangeSchema( + start=realized_start, + end=realized_end, + ) + } + ) ) - } - ) - if occurrence.id in scheduled - and _coerce_project(occurrence.schedulable_timerange.start) - <= _coerce_project(scheduled[occurrence.id].realized_start) - <= _coerce_project(scheduled[occurrence.id].realized_end) - <= _coerce_project(occurrence.schedulable_timerange.end) - else occurrence - for occurrence in occurrences - ] + else: + expanded.append(occurrence) + occurrences = expanded occurrences.sort(key=lambda item: item.default_scheduled_timerange.start) return occurrences diff --git a/src/elastisched/api/router.py b/backend/elastisched_api/router.py similarity index 97% rename from src/elastisched/api/router.py rename to backend/elastisched_api/router.py index 86ee54e..66615df 100644 --- a/src/elastisched/api/router.py +++ b/backend/elastisched_api/router.py @@ -5,10 +5,10 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from elastisched.api.db import get_session -from elastisched.api.models import BlobModel -from elastisched.api.schemas import BlobCreate, BlobRead, BlobUpdate, TimeRangeSchema -from elastisched.constants import DEFAULT_TZ, PROJECT_TIMEZONE +from elastisched_api.db import get_session +from elastisched_api.models import BlobModel +from elastisched_api.schemas import BlobCreate, BlobRead, BlobUpdate, TimeRangeSchema +from core.constants import DEFAULT_TZ, PROJECT_TIMEZONE router = APIRouter(prefix="/blobs", tags=["blobs"]) diff --git a/src/elastisched/api/schedule_router.py b/backend/elastisched_api/schedule_router.py similarity index 60% rename from src/elastisched/api/schedule_router.py rename to backend/elastisched_api/schedule_router.py index e58f722..a16a8b8 100644 --- a/src/elastisched/api/schedule_router.py +++ b/backend/elastisched_api/schedule_router.py @@ -7,27 +7,29 @@ from sqlalchemy.ext.asyncio import AsyncSession import engine -from elastisched.api.db import get_session -from elastisched.api.models import ( +from elastisched_api.db import get_session +from elastisched_api.models import ( RecurrenceModel, ScheduledOccurrenceModel, ScheduleStateModel, ) -from elastisched.api.recurrence_router import ( +from elastisched_api.recurrence_router import ( _coerce_timerange, _exclusion_set, + _payload_end_datetime, + _parse_datetime, _recurrence_from_payload, _recurrence_tzinfo, _to_occurrence_schema, ) -from elastisched.api.schemas import ( +from elastisched_api.schemas import ( ScheduleRequest, ScheduleResponse, ScheduleStatus, TimeRangeSchema, ) -from elastisched.constants import DEFAULT_TZ -from elastisched.timerange import TimeRange +from core.constants import DEFAULT_TZ +from core.timerange import TimeRange schedule_router = APIRouter(prefix="/schedule", tags=["schedule"]) @@ -49,6 +51,62 @@ def _resolve_user_tz(name: str | None): return DEFAULT_TZ +def _occurrence_override(payload: dict, occurrence) -> dict | None: + overrides = payload.get("occurrence_overrides") if isinstance(payload, dict) else None + if not isinstance(overrides, dict): + return None + occurrence_start = occurrence.schedulable_timerange.start + occurrence_ts = int(occurrence_start.timestamp()) + for key, value in overrides.items(): + if not isinstance(value, dict): + continue + try: + key_dt = _parse_datetime(key) + except HTTPException: + continue + if key_dt.tzinfo is None: + key_dt = key_dt.replace(tzinfo=occurrence_start.tzinfo) + else: + key_dt = key_dt.astimezone(occurrence_start.tzinfo) + if int(key_dt.timestamp()) == occurrence_ts: + return value + return None + + +def _override_finished_at(override: dict, tzinfo): + if not isinstance(override, dict): + return None + raw = override.get("finished_at") + if not raw: + return None + try: + finished_at = _parse_datetime(raw) + except HTTPException: + return None + if tzinfo: + if finished_at.tzinfo is None: + finished_at = finished_at.replace(tzinfo=tzinfo) + else: + finished_at = finished_at.astimezone(tzinfo) + return finished_at + + +def _occurrence_effective_end(occurrence, override: dict | None): + base_end = occurrence.default_scheduled_timerange.end + tzinfo = base_end.tzinfo + finished_at = _override_finished_at(override, tzinfo) + if finished_at: + return finished_at + added_minutes = override.get("added_minutes") if isinstance(override, dict) else None + try: + added_minutes = float(added_minutes or 0) + except (TypeError, ValueError): + added_minutes = 0 + if added_minutes: + return base_end + timedelta(minutes=added_minutes) + return base_end + + def _epoch_start_utc(reference_utc: datetime, user_tz) -> datetime: reference_local = _as_utc(reference_utc).astimezone(user_tz) start_local = reference_local - timedelta(days=reference_local.weekday()) @@ -76,7 +134,13 @@ def _policy_from_payload(policy) -> engine.Policy: or policy.get("min_split_duration") or 0 ) - return engine.Policy(max_splits, min_split_duration, int(scheduling_policies)) + round_to_granularity = bool(policy.get("round_to_granularity") or False) + return engine.Policy( + max_splits, + min_split_duration, + int(scheduling_policies), + round_to_granularity, + ) def _tags_from_payload(raw_tags) -> set: @@ -128,28 +192,39 @@ def _dependency_violation_message(jobs: list[engine.Job]) -> str | None: if len(topo_order) != len(jobs): return "Cyclic dependencies detected." - processed = set() for job in jobs: + job_ranges = job.scheduledTimeRanges or [job.scheduledTimeRange] + earliest_start = min(job_range.getLow() for job_range in job_ranges) for dep_id in job.dependencies: - if dep_id in job_map and dep_id not in processed: + dep_job = job_map.get(dep_id) + if dep_job is None: + continue + dep_ranges = dep_job.scheduledTimeRanges or [dep_job.scheduledTimeRange] + latest_end = max(dep_range.getHigh() for dep_range in dep_ranges) + if latest_end > earliest_start: return f"Dependency order violation for {job.id}." - processed.add(job.id) return None def _validate_schedule(schedule: engine.Schedule) -> str | None: jobs = list(schedule.scheduledJobs) for job in jobs: - if not job.schedulableTimeRange.contains(job.scheduledTimeRange): - return f"{job.id} scheduled outside schedulable window." + job_ranges = job.scheduledTimeRanges or [job.scheduledTimeRange] + for job_range in job_ranges: + if not job.schedulableTimeRange.contains(job_range): + return f"{job.id} scheduled outside schedulable window." non_overlappable = [] for job in jobs: if job.policy.isOverlappable(): continue for other in non_overlappable: - if job.scheduledTimeRange.overlaps(other.scheduledTimeRange): - return f"{job.id} overlaps with {other.id}." + job_ranges = job.scheduledTimeRanges or [job.scheduledTimeRange] + other_ranges = other.scheduledTimeRanges or [other.scheduledTimeRange] + for job_range in job_ranges: + for other_range in other_ranges: + if job_range.overlaps(other_range): + return f"{job.id} overlaps with {other.id}." non_overlappable.append(job) return _dependency_violation_message(jobs) @@ -198,10 +273,17 @@ async def run_schedule( recurrence_obj = _recurrence_from_payload(recurrence.type, recurrence.payload) recurrence_tz = _recurrence_tzinfo(recurrence_obj) recurrence_range = _coerce_timerange(timerange, recurrence_tz) + end_date = _payload_end_datetime(recurrence.payload or {}, recurrence_tz) + if end_date and end_date < recurrence_range.start: + continue + if end_date and end_date < recurrence_range.end: + recurrence_range = TimeRange(start=recurrence_range.start, end=end_date) for blob in recurrence_obj.all_occurrences(recurrence_range): if not recurrence_range.contains(blob.get_schedulable_timerange()): continue sched_start = blob.get_schedulable_timerange().start + if end_date and sched_start > end_date: + continue if sched_start.tzinfo is None: sched_start = sched_start.replace(tzinfo=timezone.utc) if int(sched_start.timestamp()) in exclusions: @@ -215,9 +297,25 @@ async def run_schedule( epoch_start_utc = _epoch_start_utc(start_utc, user_tz) granularity_minutes = max(1, int(payload.granularity_minutes or 5)) granularity_seconds = granularity_minutes * 60 + include_active = True if payload.include_active_occurrences is None else bool( + payload.include_active_occurrences + ) + now_utc = datetime.now(timezone.utc) jobs = [] for occurrence in occurrences: + override = _occurrence_override(occurrence.recurrence_payload or {}, occurrence) + finished_at = _override_finished_at( + override, occurrence.default_scheduled_timerange.end.tzinfo + ) + if finished_at: + continue + if not include_active: + effective_end = _occurrence_effective_end(occurrence, override) + if _as_utc(occurrence.default_scheduled_timerange.start) <= now_utc < _as_utc( + effective_end + ): + continue schedulable = occurrence.schedulable_timerange scheduled = occurrence.default_scheduled_timerange if schedulable.start >= schedulable.end: @@ -269,21 +367,20 @@ async def run_schedule( await session.execute(delete(ScheduledOccurrenceModel)) scheduled_rows = [] for job in schedule.scheduledJobs: - realized_start_utc = epoch_start_utc + timedelta( - seconds=job.scheduledTimeRange.getLow() - ) - realized_end_utc = epoch_start_utc + timedelta( - seconds=job.scheduledTimeRange.getHigh() - ) - realized_start = realized_start_utc.astimezone(DEFAULT_TZ) - realized_end = realized_end_utc.astimezone(DEFAULT_TZ) - scheduled_rows.append( - ScheduledOccurrenceModel( - id=job.id, - realized_start=realized_start, - realized_end=realized_end, + ranges = job.scheduledTimeRanges or [job.scheduledTimeRange] + for segment_index, job_range in enumerate(ranges): + realized_start_utc = epoch_start_utc + timedelta(seconds=job_range.getLow()) + realized_end_utc = epoch_start_utc + timedelta(seconds=job_range.getHigh()) + realized_start = realized_start_utc.astimezone(DEFAULT_TZ) + realized_end = realized_end_utc.astimezone(DEFAULT_TZ) + scheduled_rows.append( + ScheduledOccurrenceModel( + id=job.id, + segment_index=segment_index, + realized_start=realized_start, + realized_end=realized_end, + ) ) - ) if scheduled_rows: session.add_all(scheduled_rows) @@ -293,21 +390,28 @@ async def run_schedule( await session.commit() await session.refresh(state) - scheduled_map = {row.id: row for row in scheduled_rows} - if scheduled_map: - occurrences = [ - occurrence.model_copy( - update={ - "realized_timerange": TimeRangeSchema( - start=scheduled_map[occurrence.id].realized_start, - end=scheduled_map[occurrence.id].realized_end, + if scheduled_rows: + scheduled_map: dict[str, list[ScheduledOccurrenceModel]] = {} + for row in scheduled_rows: + scheduled_map.setdefault(row.id, []).append(row) + expanded = [] + for occurrence in occurrences: + rows = scheduled_map.get(occurrence.id) + if not rows: + expanded.append(occurrence) + continue + for row in rows: + expanded.append( + occurrence.model_copy( + update={ + "realized_timerange": TimeRangeSchema( + start=row.realized_start, + end=row.realized_end, + ) + } ) - } - ) - if occurrence.id in scheduled_map - else occurrence - for occurrence in occurrences - ] + ) + occurrences = expanded return ScheduleResponse( occurrences=occurrences, diff --git a/src/elastisched/api/schemas.py b/backend/elastisched_api/schemas.py similarity index 97% rename from src/elastisched/api/schemas.py rename to backend/elastisched_api/schemas.py index ba73837..e6fc34f 100644 --- a/src/elastisched/api/schemas.py +++ b/backend/elastisched_api/schemas.py @@ -76,6 +76,7 @@ class ScheduleRequest(BaseModel): granularity_minutes: int | None = Field(default=None, ge=1) lookahead_seconds: int | None = Field(default=None, ge=1) user_timezone: str | None = Field(default=None, max_length=64) + include_active_occurrences: bool | None = None class ScheduleResponse(ScheduleStatus): diff --git a/src/elastisched/__init__.py b/core/__init__.py similarity index 100% rename from src/elastisched/__init__.py rename to core/__init__.py diff --git a/src/elastisched/blob.py b/core/blob.py similarity index 98% rename from src/elastisched/blob.py rename to core/blob.py index 402315a..3c44e51 100644 --- a/src/elastisched/blob.py +++ b/core/blob.py @@ -4,8 +4,8 @@ from datetime import datetime, timezone from typing import Iterable, List, Optional, Set -from elastisched.timerange import TimeRange -from elastisched.constants import * +from .timerange import TimeRange +from .constants import * from engine import Policy, Tag, Job, TimeRange as tr diff --git a/src/elastisched/constants.py b/core/constants.py similarity index 100% rename from src/elastisched/constants.py rename to core/constants.py diff --git a/src/elastisched/daytime.py b/core/daytime.py similarity index 100% rename from src/elastisched/daytime.py rename to core/daytime.py diff --git a/src/elastisched/recurrence.py b/core/recurrence.py similarity index 71% rename from src/elastisched/recurrence.py rename to core/recurrence.py index 08cbd69..7ebf7e1 100644 --- a/src/elastisched/recurrence.py +++ b/core/recurrence.py @@ -1,13 +1,13 @@ from abc import ABC, abstractmethod from copy import deepcopy from dataclasses import dataclass, field -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import List, Optional import uuid -from elastisched.blob import Blob -from elastisched.daytime import daytime -from engine import TimeRange +from .blob import Blob +from .daytime import daytime +from .timerange import TimeRange def has_overlapping_blobs(blobs: List[Blob]) -> bool: @@ -28,6 +28,124 @@ def _coerce_datetime(value: datetime, tzinfo) -> datetime: return value.astimezone(tzinfo) +def _coerce_datetime_local_naive(value: datetime, tzinfo) -> datetime: + if tzinfo is None: + return value + if value.tzinfo is None: + return value.replace(tzinfo=tzinfo).replace(tzinfo=None) + return value.astimezone(tzinfo).replace(tzinfo=None) + + +def _resolve_local_datetime( + target_local_naive: datetime, tzinfo, reference_project: Optional[datetime] = None +) -> datetime: + if tzinfo is None: + return target_local_naive + + candidate0 = target_local_naive.replace(tzinfo=tzinfo, fold=0) + candidate1 = target_local_naive.replace(tzinfo=tzinfo, fold=1) + + if candidate0.utcoffset() == candidate1.utcoffset(): + return candidate0 + + roundtrip0 = candidate0.astimezone(timezone.utc).astimezone(tzinfo) + roundtrip1 = candidate1.astimezone(timezone.utc).astimezone(tzinfo) + + if roundtrip0 != candidate0 and roundtrip1 != candidate1: + # Spring-forward gap: roll forward to the first valid local time. + return roundtrip0 + + # Fall-back ambiguity: choose the earliest candidate not before the reference. + if reference_project is None: + return candidate0 + + ref = ( + reference_project + if reference_project.tzinfo is not None + else reference_project.replace(tzinfo=tzinfo) + ) + cand0_proj = candidate0.astimezone(ref.tzinfo) + cand1_proj = candidate1.astimezone(ref.tzinfo) + + if cand0_proj >= ref and cand1_proj >= ref: + return candidate0 if cand0_proj <= cand1_proj else candidate1 + if cand0_proj >= ref: + return candidate0 + if cand1_proj >= ref: + return candidate1 + return candidate1 if cand1_proj >= cand0_proj else candidate0 + + +def _delta_from_local( + start: datetime, + tzinfo, + target_local_naive: datetime, + reference_project: Optional[datetime] = None, +) -> timedelta: + target_local = _resolve_local_datetime( + target_local_naive, tzinfo, reference_project + ) + target_project = ( + target_local.astimezone(start.tzinfo) if start.tzinfo else target_local + ) + return target_project - start + + +def _project_datetime_from_local( + base_project: datetime, + tzinfo, + target_local_naive: datetime, + reference_project: Optional[datetime] = None, +) -> datetime: + target_local = _resolve_local_datetime( + target_local_naive, tzinfo, reference_project + ) + if base_project.tzinfo: + return target_local.astimezone(base_project.tzinfo) + return target_local + + +def _timerange_shift_local( + timerange: TimeRange, + tzinfo, + delta_local: timedelta, + reference_project: Optional[datetime] = None, +) -> TimeRange: + start_local = _coerce_datetime_local_naive(timerange.start, tzinfo) + end_local = _coerce_datetime_local_naive(timerange.end, tzinfo) + local_duration = end_local - start_local + target_local_start = start_local + delta_local + target_local_end = target_local_start + local_duration + + target_start = _project_datetime_from_local( + timerange.start, tzinfo, target_local_start, reference_project + ) + target_end = _project_datetime_from_local( + timerange.end, tzinfo, target_local_end, target_start + ) + return TimeRange(start=target_start, end=target_end) + + +def blob_copy_with_local_delta( + blob: Blob, + tzinfo, + delta_local: timedelta, + reference_project: Optional[datetime] = None, +): + blob_copy = deepcopy(blob) + default_tr = blob_copy.get_default_scheduled_timerange() + schedulable_tr = blob_copy.get_schedulable_timerange() + + blob_copy.set_default_scheduled_timerange( + _timerange_shift_local(default_tr, tzinfo, delta_local, reference_project) + ) + blob_copy.set_schedulable_timerange( + _timerange_shift_local(schedulable_tr, tzinfo, delta_local, reference_project) + ) + + return blob_copy + + def blob_copy_with_delta_future(blob: Blob, td: timedelta): blob_copy = deepcopy(blob) @@ -88,6 +206,36 @@ def all_occurrences(self, timerange: TimeRange) -> List[Blob]: return [] +@dataclass +class MultipleBlobOccurrence(BlobRecurrence): + blobs: List[Blob] + + def __post_init__(self): + if not self.blobs: + raise ValueError("Multiple occurrence requires at least one blob") + self.blobs.sort(key=lambda blob: blob.get_schedulable_timerange().start) + + def next_occurrence(self, current: datetime) -> Optional[Blob]: + candidates = [] + for blob in self.blobs: + timerange = blob.get_schedulable_timerange() + current_local = _coerce_datetime(current, timerange.start.tzinfo) + if current_local < timerange.start: + candidates.append(blob) + if not candidates: + return None + candidates.sort(key=lambda item: item.get_schedulable_timerange().start) + return deepcopy(candidates[0]) + + def all_occurrences(self, timerange: TimeRange) -> List[Blob]: + occurrences = [] + for blob in self.blobs: + if timerange.contains(blob.get_schedulable_timerange()): + occurrences.append(deepcopy(blob)) + occurrences.sort(key=lambda item: item.get_schedulable_timerange().start) + return occurrences + + @dataclass class WeeklyBlobRecurrence(BlobRecurrence): """Weekly recurrence rule""" @@ -128,10 +276,11 @@ def next_occurrence(self, current: datetime) -> Optional[Blob]: total_days = (current_local - base_start_local).days weeks_since_start = max(0, total_days // 7) # clamp to 0 if before start + interval_start = (weeks_since_start // self.interval) * self.interval candidate_weeks = [ - weeks_since_start * self.interval, - (weeks_since_start + 1) * self.interval, + interval_start, + interval_start + self.interval, ] for week_offset in candidate_weeks: @@ -173,10 +322,11 @@ def all_occurrences(self, timerange: TimeRange) -> List[Blob]: if next_blob is None: break - if not timerange.contains(next_blob.get_schedulable_timerange()): + occurrence_range = next_blob.get_schedulable_timerange() + if not timerange.overlaps(occurrence_range): break - next_start = next_blob.get_schedulable_timerange().start + next_start = occurrence_range.start if next_start > timerange.end: break @@ -202,15 +352,21 @@ def __post_init__(self): def next_occurrence(self, current: datetime) -> Optional[Blob]: start = self.start_blob.get_schedulable_timerange().start - current_local = _coerce_datetime(current, start.tzinfo) - if current_local < start: + tzinfo = self.start_blob.tz or start.tzinfo + current_project = _coerce_datetime(current, start.tzinfo) + start_local = _coerce_datetime_local_naive(start, tzinfo) + current_local = _coerce_datetime_local_naive(current, tzinfo) + if current_local < start_local: return deepcopy(self.start_blob) - time_diff = current_local - start + time_diff = current_local - start_local intervals_passed = time_diff // self.delta - delta_to_occurrence = (intervals_passed + 1) * self.delta + target_local = start_local + (intervals_passed + 1) * self.delta + delta_local = target_local - start_local - return blob_copy_with_delta_future(self.start_blob, delta_to_occurrence) + return blob_copy_with_local_delta( + self.start_blob, tzinfo, delta_local, current_project + ) def all_occurrences(self, timerange: TimeRange) -> List[Blob]: occurrences = [] @@ -218,35 +374,41 @@ def all_occurrences(self, timerange: TimeRange) -> List[Blob]: start_schedulable_timerange = self.start_blob.get_schedulable_timerange() start = timerange.start end = timerange.end + base_start = start_schedulable_timerange.start + tzinfo = self.start_blob.tz or base_start.tzinfo # Determine the first occurrence to consider - if start <= start_schedulable_timerange.start: - curr_datetime = start_schedulable_timerange.start + start_local = _coerce_datetime_local_naive(base_start, tzinfo) + range_start_local = _coerce_datetime_local_naive(start, tzinfo) + range_end_local = _coerce_datetime_local_naive(end, tzinfo) + + if range_start_local <= start_local: + curr_local = start_local else: - time_diff = start - start_schedulable_timerange.start + time_diff = range_start_local - start_local intervals_passed = time_diff // self.delta - curr_datetime = ( - start_schedulable_timerange.start + intervals_passed * self.delta - ) - - if curr_datetime < start: - curr_datetime += self.delta + curr_local = start_local + intervals_passed * self.delta - while curr_datetime <= end: - time_diff = curr_datetime - start_schedulable_timerange.start + while curr_local <= range_end_local: + time_diff = curr_local - start_local intervals_passed = time_diff // self.delta - delta_to_occurrence = intervals_passed * self.delta + target_local = start_local + intervals_passed * self.delta + delta_local = target_local - start_local - blob_copy = blob_copy_with_delta_future( - self.start_blob, delta_to_occurrence + blob_copy = blob_copy_with_local_delta( + self.start_blob, tzinfo, delta_local, start ) - if timerange.contains(blob_copy.get_schedulable_timerange()): + occurrence_range = blob_copy.get_schedulable_timerange() + if timerange.overlaps(occurrence_range): occurrences.append(blob_copy) + elif occurrence_range.end <= start: + curr_local += self.delta + continue else: break - curr_datetime += self.delta + curr_local += self.delta return occurrences diff --git a/src/elastisched/schedule.py b/core/schedule.py similarity index 93% rename from src/elastisched/schedule.py rename to core/schedule.py index 36a376d..0318e49 100644 --- a/src/elastisched/schedule.py +++ b/core/schedule.py @@ -1,8 +1,8 @@ from datetime import datetime, timedelta, timezone from dataclasses import dataclass, field -from elastisched.blob import Blob -from elastisched.constants import DEFAULT_TZ -from elastisched.timerange import TimeRange +from .blob import Blob +from .constants import DEFAULT_TZ +from .timerange import TimeRange from engine import schedule from typing import List, Tuple, Dict diff --git a/src/elastisched/timerange.py b/core/timerange.py similarity index 97% rename from src/elastisched/timerange.py rename to core/timerange.py index c493e53..2cb23cd 100644 --- a/src/elastisched/timerange.py +++ b/core/timerange.py @@ -3,7 +3,7 @@ from functools import wraps from typing import Tuple -from elastisched.constants import DEFAULT_END_DATE, DEFAULT_START_DATE, DEFAULT_TZ +from .constants import DEFAULT_END_DATE, DEFAULT_START_DATE, DEFAULT_TZ def validate_timezone_compatibility(func): diff --git a/src/elastisched/utils.py b/core/utils.py similarity index 97% rename from src/elastisched/utils.py rename to core/utils.py index 926857b..59981d5 100644 --- a/src/elastisched/utils.py +++ b/core/utils.py @@ -3,7 +3,7 @@ from enum import Enum from math import ceil -from elastisched.constants import * +from .constants import * class Day(Enum): diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0bbfda0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,10 @@ +services: + api: + build: . + command: ["uvicorn", "elastisched_api.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] + ports: + - "8000:8000" + environment: + DATABASE_URL: sqlite+aiosqlite:///./elastisched.db + volumes: + - ./:/app diff --git a/electron/main.js b/electron/main.js new file mode 100644 index 0000000..e149496 --- /dev/null +++ b/electron/main.js @@ -0,0 +1,129 @@ +const { app, BrowserWindow } = require("electron"); +const { spawn } = require("child_process"); +const fs = require("fs"); +const http = require("http"); +const path = require("path"); + +const API_HOST = "127.0.0.1"; +const API_PORT = 8000; +const API_URL = `http://${API_HOST}:${API_PORT}`; +const UI_URL = `${API_URL}/ui`; + +let backendProcess = null; + +function ensureDirExists(dirPath) { + fs.mkdirSync(dirPath, { recursive: true }); +} + +function waitForHealth({ retries = 80, delayMs = 300 } = {}) { + return new Promise((resolve, reject) => { + let attempts = 0; + + const tryOnce = () => { + attempts += 1; + const req = http.get(`${API_URL}/health`, (res) => { + res.resume(); + if (res.statusCode === 200) { + resolve(); + return; + } + if (attempts >= retries) { + reject(new Error(`Health check failed with status ${res.statusCode}`)); + return; + } + setTimeout(tryOnce, delayMs); + }); + + req.on("error", () => { + if (attempts >= retries) { + reject(new Error("Health check failed")); + return; + } + setTimeout(tryOnce, delayMs); + }); + }; + + tryOnce(); + }); +} + +function startBackend() { + const repoRoot = path.resolve(__dirname, ".."); + const userDataDir = app.getPath("userData"); + ensureDirExists(userDataDir); + + const dbPath = path.join(userDataDir, "elastisched.db"); + const env = { + ...process.env, + PYTHONPATH: path.join(repoRoot, "backend"), + DATABASE_URL: `sqlite+aiosqlite:///${dbPath}`, + }; + + const python = process.env.ELASTISCHED_PYTHON || "python3"; + backendProcess = spawn( + python, + [ + "-m", + "uvicorn", + "elastisched_api.main:app", + "--host", + API_HOST, + "--port", + String(API_PORT), + ], + { + cwd: repoRoot, + env, + stdio: "inherit", + } + ); + + backendProcess.on("exit", (code) => { + if (code !== 0) { + console.error(`Backend exited with code ${code}`); + } + }); +} + +function stopBackend() { + if (!backendProcess) return; + backendProcess.kill(); + backendProcess = null; +} + +async function createWindow() { + const window = new BrowserWindow({ + width: 1280, + height: 800, + webPreferences: { + preload: path.join(__dirname, "preload.js"), + }, + }); + + try { + await waitForHealth(); + await window.loadURL(UI_URL); + } catch (error) { + await window.loadURL( + `data:text/plain,Failed%20to%20start%20backend.%0A${encodeURIComponent( + error.message || String(error) + )}` + ); + } +} + +app.whenReady().then(() => { + startBackend(); + return createWindow(); +}); + +app.on("window-all-closed", () => { + stopBackend(); + if (process.platform !== "darwin") { + app.quit(); + } +}); + +app.on("before-quit", () => { + stopBackend(); +}); diff --git a/electron/package-lock.json b/electron/package-lock.json new file mode 100644 index 0000000..7e98450 --- /dev/null +++ b/electron/package-lock.json @@ -0,0 +1,801 @@ +{ + "name": "elastisched-desktop", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "elastisched-desktop", + "version": "0.1.0", + "devDependencies": { + "electron": "^30.0.0" + } + }, + "node_modules/@electron/get": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", + "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", + "dev": true, + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dev": true, + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "dev": true, + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", + "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==", + "dev": true + }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "20.19.28", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.28.tgz", + "integrity": "sha512-VyKBr25BuFDzBFCK5sUM6ZXiWfqgCTwTAOK8qzGV/m9FCirXYDlmczJ+d5dXBAQALGCdRRdbteKYfJ84NGEusw==", + "dev": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dev": true, + "optional": true + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "dev": true, + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "dev": true, + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "dev": true, + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "optional": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true, + "optional": true + }, + "node_modules/electron": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/electron/-/electron-30.5.1.tgz", + "integrity": "sha512-AhL7+mZ8Lg14iaNfoYTkXQ2qee8mmsQyllKdqxlpv/zrKgfxz6jNVtcRRbQtLxtF8yzcImWdfTQROpYiPumdbw==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "@electron/get": "^2.0.0", + "@types/node": "^20.9.0", + "extract-zip": "^2.0.1" + }, + "bin": { + "electron": "cli.js" + }, + "engines": { + "node": ">= 12.20.55" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true, + "optional": true + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "dev": true, + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/global-agent/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "optional": true, + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "optional": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "dev": true, + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true + }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dev": true, + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "optional": true + }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "dev": true, + "optional": true, + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "dev": true, + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true + }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "dev": true, + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "dev": true, + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "dev": true, + "optional": true + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "dev": true, + "optional": true, + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, + "optional": true + }, + "node_modules/sumchecker": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", + "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "dev": true, + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true, + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true + }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + } + } +} diff --git a/electron/package.json b/electron/package.json new file mode 100644 index 0000000..da379c9 --- /dev/null +++ b/electron/package.json @@ -0,0 +1,13 @@ +{ + "name": "elastisched-desktop", + "version": "0.1.0", + "private": true, + "main": "main.js", + "scripts": { + "dev": "electron .", + "start": "electron ." + }, + "devDependencies": { + "electron": "^30.0.0" + } +} diff --git a/src/elastisched/engine/CMakeLists.txt b/engine/CMakeLists.txt similarity index 100% rename from src/elastisched/engine/CMakeLists.txt rename to engine/CMakeLists.txt diff --git a/engine/bin/engine b/engine/bin/engine new file mode 100755 index 0000000..a59da7d Binary files /dev/null and b/engine/bin/engine differ diff --git a/src/elastisched/engine/src/constants.hpp b/engine/src/constants.hpp similarity index 96% rename from src/elastisched/engine/src/constants.hpp rename to engine/src/constants.hpp index e3253a8..ee4c0bd 100644 --- a/src/elastisched/engine/src/constants.hpp +++ b/engine/src/constants.hpp @@ -29,6 +29,7 @@ namespace constants { constexpr double EXP_DOWNFACTOR = 0.1f; constexpr double HOURLY_COST_FACTOR = 1.0f; + constexpr double SPLIT_COST_FACTOR = 10.0f; const Tag WORK_TAG = Tag("ELASTISCHED_WORK_TYPE"); constexpr double ILLEGAL_SCHEDULE_COST = 1e12f; diff --git a/src/elastisched/engine/src/cost_function.cpp b/engine/src/cost_function.cpp similarity index 57% rename from src/elastisched/engine/src/cost_function.cpp rename to engine/src/cost_function.cpp index 4d31b19..2361de0 100644 --- a/src/elastisched/engine/src/cost_function.cpp +++ b/engine/src/cost_function.cpp @@ -24,6 +24,13 @@ std::optional safemax(std::optional u, std::optional v) { return std::nullopt; } +std::vector getJobScheduledRanges(const Job& job) { + if (!job.scheduledTimeRanges.empty()) { + return job.scheduledTimeRanges; + } + return {job.scheduledTimeRange}; +} + /** * getTotalJobsLength * @@ -34,7 +41,9 @@ std::optional safemax(std::optional u, std::optional v) { time_t getTotalJobsLength(std::vector jobs) { time_t length = 0; for (const auto& job : jobs) { - length += job.scheduledTimeRange.length(); + for (const auto& range : getJobScheduledRanges(job)) { + length += range.length(); + } } return length; } @@ -47,8 +56,10 @@ m_granularity(granularity) if (schedule.scheduledJobs.size() == 0) return; for (const auto& job : schedule.scheduledJobs) { - m_min = safemin(m_min, job.scheduledTimeRange.getLow()); - m_max = safemax(m_max, job.scheduledTimeRange.getHigh()); + for (const auto& range : getJobScheduledRanges(job)) { + m_min = safemin(m_min, range.getLow()); + m_max = safemax(m_max, range.getHigh()); + } } TimeRange curr = TimeRange(0, constants::DAY - 1); @@ -62,10 +73,12 @@ m_granularity(granularity) } for (const auto& job : schedule.scheduledJobs) { - TimeRange currInterval = TimeRange(job.scheduledTimeRange.getLow()); // TimeRange representing unit of time - std::optional>* currDayJobs = m_dayBasedSchedule.searchValue(currInterval); - if (currDayJobs) { - currDayJobs->emplace().push_back(job); + for (const auto& range : getJobScheduledRanges(job)) { + TimeRange currInterval = TimeRange(range.getLow()); // TimeRange representing unit of time + std::optional>* currDayJobs = m_dayBasedSchedule.searchValue(currInterval); + if (currDayJobs) { + currDayJobs->emplace().push_back(job); + } } } } @@ -82,19 +95,18 @@ double ScheduleCostFunction::busy_afternoon_exponential_cost(uint64_t DAYS_SINCE double cost = 0; while (currDay.getHigh() < m_max.value()) { if (currJobs->has_value()) { - std::vector filteredWorkJobs; for (const auto& job : currJobs->value()) { bool is_work_type = job.tags.find(constants::WORK_TAG) != job.tags.end(); - /* scheduledTimeRange.low := DAY * day (since EPOCH) + HOUR * hour + MINUTES * minute + second */ - bool is_scheduled_in_afternoon = ((job.scheduledTimeRange.getLow() / constants::HOUR) % 24) >= constants::AFTERNOON_START; - if (is_work_type && is_scheduled_in_afternoon) { - filteredWorkJobs.push_back(job); + if (!is_work_type) { + continue; + } + for (const auto& range : getJobScheduledRanges(job)) { + /* scheduledTimeRange.low := DAY * day (since EPOCH) + HOUR * hour + MINUTES * minute + second */ + bool is_scheduled_in_afternoon = ((range.getLow() / constants::HOUR) % 24) >= constants::AFTERNOON_START; + if (is_scheduled_in_afternoon) { + cost += std::exp(constants::EXP_DOWNFACTOR * ((double)(range.length()) / constants::HOUR)); + } } - } - - /* Apply greater cost for work type jobs scheduled later in the afternoon */ - for (const auto& job : filteredWorkJobs) { - cost += std::exp(constants::EXP_DOWNFACTOR * ((double)(job.scheduledTimeRange.length()) / constants::HOUR)); } } currDay = TimeRange(currDay.getHigh() + constants::WEEK); @@ -123,7 +135,9 @@ double ScheduleCostFunction::busy_day_constant_cost(uint64_t DAYS_SINCE_MONDAY) } for (const auto& job : filteredWorkJobs) { - cost += constants::HOURLY_COST_FACTOR * ((double)(job.scheduledTimeRange.length()) / constants::HOUR); + for (const auto& range : getJobScheduledRanges(job)) { + cost += constants::HOURLY_COST_FACTOR * ((double)(range.length()) / constants::HOUR); + } } } currDay = TimeRange(currDay.getHigh() + constants::WEEK); @@ -156,28 +170,30 @@ double ScheduleCostFunction::busy_saturday_cost() const { double ScheduleCostFunction::illegal_schedule_cost() const { const std::vector& scheduledJobs = m_schedule.scheduledJobs; IntervalTree nonOverlappableJobs; - + for (size_t i = 0; i < scheduledJobs.size(); ++i) { const Job& curr = scheduledJobs[i]; Policy currPolicy = curr.policy; - if (!curr.schedulableTimeRange.contains(curr.scheduledTimeRange)) { - return constants::ILLEGAL_SCHEDULE_COST; - } - - if (!currPolicy.isOverlappable()) { - auto overlappingInterval = nonOverlappableJobs.searchOverlap( - curr.scheduledTimeRange - ); - - if (overlappingInterval != nullptr) { + for (const auto& range : getJobScheduledRanges(curr)) { + if (!curr.schedulableTimeRange.contains(range)) { return constants::ILLEGAL_SCHEDULE_COST; } - - nonOverlappableJobs.insert( - curr.scheduledTimeRange, - i - ); + + if (!currPolicy.isOverlappable()) { + auto overlappingInterval = nonOverlappableJobs.searchOverlap( + range + ); + + if (overlappingInterval != nullptr) { + return constants::ILLEGAL_SCHEDULE_COST; + } + + nonOverlappableJobs.insert( + range, + i + ); + } } } @@ -189,8 +205,45 @@ double ScheduleCostFunction::illegal_schedule_cost() const { return 0.0f; } +/** + * @brief Adds a cost which helps reduce the amount of overlap that a schedule will accept + * + * @return double + */ +double ScheduleCostFunction::overlap_cost() const { + const std::vector& scheduledJobs = m_schedule.scheduledJobs; + if (scheduledJobs.size() < 2) { + return 0.0f; + } + const double granularity = m_granularity > 0 ? static_cast(m_granularity) : 1.0; + IntervalTree overlapTree; + double cost = 0.0f; + for (size_t i = 0; i < scheduledJobs.size(); ++i) { + for (const auto& current : getJobScheduledRanges(scheduledJobs[i])) { + const auto overlaps = overlapTree.findOverlapping(current); + for (const auto* interval : overlaps) { + cost += static_cast(current.overlap_length(*interval)) / granularity; + } + overlapTree.insert(current, i); + } + } + return cost; +} + +double ScheduleCostFunction::split_cost() const { + const std::vector& scheduledJobs = m_schedule.scheduledJobs; + double cost = 0.0f; + for (const auto& job : scheduledJobs) { + const auto ranges = getJobScheduledRanges(job); + if (ranges.size() > 1) { + cost += (static_cast(ranges.size() - 1) * constants::SPLIT_COST_FACTOR); + } + } + return cost; +} + double ScheduleCostFunction::scheduleCost() const { - double cost = busy_friday_afternoon_cost() + busy_saturday_cost() + illegal_schedule_cost(); + double cost = illegal_schedule_cost() + overlap_cost() + split_cost(); return cost; } diff --git a/src/elastisched/engine/src/cost_function.hpp b/engine/src/cost_function.hpp similarity index 94% rename from src/elastisched/engine/src/cost_function.hpp rename to engine/src/cost_function.hpp index 09d825b..47ac2f6 100644 --- a/src/elastisched/engine/src/cost_function.hpp +++ b/engine/src/cost_function.hpp @@ -36,8 +36,10 @@ class ScheduleCostFunction { // double priority_inversion_cost() const; double illegal_schedule_cost() const; + double overlap_cost() const; + double split_cost() const; double scheduleCost() const; ScheduleCostFunction(const Schedule& schedule, time_t granularity); }; -#endif \ No newline at end of file +#endif diff --git a/src/elastisched/engine/src/job.cpp b/engine/src/job.cpp similarity index 72% rename from src/elastisched/engine/src/job.cpp rename to engine/src/job.cpp index fe29882..9cc71a0 100644 --- a/src/elastisched/engine/src/job.cpp +++ b/engine/src/job.cpp @@ -5,6 +5,7 @@ Job::Job(time_t duration, TimeRange schedulableTimeRange, TimeRange scheduledTim : duration(duration), schedulableTimeRange(schedulableTimeRange), scheduledTimeRange(scheduledTimeRange), + scheduledTimeRanges({scheduledTimeRange}), id(id), policy(policy), dependencies(dependencies), @@ -17,6 +18,17 @@ bool Job::isRigid() const { return duration == schedulableTimeRange.length(); }; +const std::vector& Job::getScheduledTimeRanges() const { + return scheduledTimeRanges; +} + +void Job::setScheduledTimeRanges(std::vector ranges) { + scheduledTimeRanges = std::move(ranges); + if (!scheduledTimeRanges.empty()) { + scheduledTimeRange = scheduledTimeRanges.front(); + } +} + std::string Job::toString() const { std::ostringstream oss; @@ -33,10 +45,14 @@ std::string Job::toString() const { // Format scheduled time range oss << "├─ Scheduled: "; - if (scheduledTimeRange.length() > 0) { - oss << "[" << scheduledTimeRange.getLow() - << " - " << scheduledTimeRange.getHigh() << "]"; - oss << " (length: " << scheduledTimeRange.length() << "s)"; + if (!scheduledTimeRanges.empty() || scheduledTimeRange.length() > 0) { + const auto& primary = scheduledTimeRanges.empty() ? scheduledTimeRange : scheduledTimeRanges.front(); + oss << "[" << primary.getLow() + << " - " << primary.getHigh() << "]"; + oss << " (length: " << primary.length() << "s)"; + if (scheduledTimeRanges.size() > 1) { + oss << " (split segments: " << scheduledTimeRanges.size() << ")"; + } } else { oss << "Not scheduled"; } @@ -88,4 +104,3 @@ std::string Job::toString() const { return oss.str(); } - diff --git a/src/elastisched/engine/src/job.hpp b/engine/src/job.hpp similarity index 77% rename from src/elastisched/engine/src/job.hpp rename to engine/src/job.hpp index a18cd35..e6b3c34 100644 --- a/src/elastisched/engine/src/job.hpp +++ b/engine/src/job.hpp @@ -16,6 +16,7 @@ class Job { time_t duration; TimeRange schedulableTimeRange; TimeRange scheduledTimeRange; + std::vector scheduledTimeRanges; ID id; Policy policy; std::set dependencies; @@ -30,7 +31,9 @@ class Job { std::set tags); bool isRigid() const; + const std::vector& getScheduledTimeRanges() const; + void setScheduledTimeRanges(std::vector ranges); std::string toString() const; }; -#endif // JOB_HPP \ No newline at end of file +#endif // JOB_HPP diff --git a/src/elastisched/engine/src/main.cpp b/engine/src/main.cpp similarity index 100% rename from src/elastisched/engine/src/main.cpp rename to engine/src/main.cpp diff --git a/src/elastisched/engine/src/optimizer/SimulatedAnnealingOptimizer.hpp b/engine/src/optimizer/SimulatedAnnealingOptimizer.hpp similarity index 100% rename from src/elastisched/engine/src/optimizer/SimulatedAnnealingOptimizer.hpp rename to engine/src/optimizer/SimulatedAnnealingOptimizer.hpp diff --git a/src/elastisched/engine/src/policy.cpp b/engine/src/policy.cpp similarity index 69% rename from src/elastisched/engine/src/policy.cpp rename to engine/src/policy.cpp index 4cedb0e..95e25e2 100644 --- a/src/elastisched/engine/src/policy.cpp +++ b/engine/src/policy.cpp @@ -1,13 +1,18 @@ #include #include "policy.hpp" -Policy::Policy(uint8_t max_splits, time_t min_split_duration, uint8_t scheduling_policies) +Policy::Policy(uint8_t max_splits, + time_t min_split_duration, + uint8_t scheduling_policies, + bool round_to_granularity) : max_splits(max_splits), min_split_duration(min_split_duration), + round_to_granularity(round_to_granularity), scheduling_policies(scheduling_policies) {} uint8_t Policy::getMaxSplits() const { return max_splits; } time_t Policy::getMinSplitDuration() const { return min_split_duration; } +bool Policy::getRoundToGranularity() const { return round_to_granularity; } uint8_t Policy::getSchedulingPolicies() const { return scheduling_policies; } bool Policy::isSplittable() const { @@ -20,4 +25,4 @@ bool Policy::isOverlappable() const { bool Policy::isInvisible() const { return (scheduling_policies & static_cast(3)) >> 2; -} \ No newline at end of file +} diff --git a/src/elastisched/engine/src/policy.hpp b/engine/src/policy.hpp similarity index 76% rename from src/elastisched/engine/src/policy.hpp rename to engine/src/policy.hpp index 0c9f32f..fd1b9cb 100644 --- a/src/elastisched/engine/src/policy.hpp +++ b/engine/src/policy.hpp @@ -18,13 +18,18 @@ class Policy { private: uint8_t max_splits; time_t min_split_duration; + bool round_to_granularity; uint8_t scheduling_policies; // Bitfield: bit 0 = is_splittable, bit 1 = is_overlappable, bit 2 = is_invisible public: - Policy(uint8_t max_splits, time_t min_split_duration, uint8_t scheduling_policies); + Policy(uint8_t max_splits = 0, + time_t min_split_duration = 0, + uint8_t scheduling_policies = 0, + bool round_to_granularity = false); uint8_t getMaxSplits() const; time_t getMinSplitDuration() const; + bool getRoundToGranularity() const; uint8_t getSchedulingPolicies() const; bool isSplittable() const; diff --git a/src/elastisched/engine/src/pybind_scheduler.cpp b/engine/src/pybind_scheduler.cpp similarity index 87% rename from src/elastisched/engine/src/pybind_scheduler.cpp rename to engine/src/pybind_scheduler.cpp index c307a64..dc13aac 100644 --- a/src/elastisched/engine/src/pybind_scheduler.cpp +++ b/engine/src/pybind_scheduler.cpp @@ -33,9 +33,19 @@ PYBIND11_MODULE(engine, m) { // Policy py::class_(m, "Policy") - .def(py::init()) + .def(py::init<>()) + .def(py::init(), + py::arg("max_splits"), + py::arg("min_split_duration"), + py::arg("scheduling_policies")) + .def(py::init(), + py::arg("max_splits"), + py::arg("min_split_duration"), + py::arg("scheduling_policies"), + py::arg("round_to_granularity")) .def("getMaxSplits", &Policy::getMaxSplits) .def("getMinSplitDuration", &Policy::getMinSplitDuration) + .def("getRoundToGranularity", &Policy::getRoundToGranularity) .def("getSchedulingPolicies", &Policy::getSchedulingPolicies) .def("isSplittable", &Policy::isSplittable) .def("isOverlappable", &Policy::isOverlappable) @@ -59,6 +69,7 @@ PYBIND11_MODULE(engine, m) { .def_readwrite("duration", &Job::duration) .def_readwrite("schedulableTimeRange", &Job::schedulableTimeRange) .def_readwrite("scheduledTimeRange", &Job::scheduledTimeRange) + .def_readwrite("scheduledTimeRanges", &Job::scheduledTimeRanges) .def_readwrite("id", &Job::id) .def_readwrite("policy", &Job::policy) .def_readwrite("dependencies", &Job::dependencies) diff --git a/src/elastisched/engine/src/schedule.cpp b/engine/src/schedule.cpp similarity index 100% rename from src/elastisched/engine/src/schedule.cpp rename to engine/src/schedule.cpp diff --git a/src/elastisched/engine/src/schedule.hpp b/engine/src/schedule.hpp similarity index 100% rename from src/elastisched/engine/src/schedule.hpp rename to engine/src/schedule.hpp diff --git a/src/elastisched/engine/src/scheduler.hpp b/engine/src/scheduler.hpp similarity index 55% rename from src/elastisched/engine/src/scheduler.hpp rename to engine/src/scheduler.hpp index 78476c7..44e1eee 100644 --- a/src/elastisched/engine/src/scheduler.hpp +++ b/engine/src/scheduler.hpp @@ -103,6 +103,7 @@ Schedule generateRandomSchedule( ); job.scheduledTimeRange = randomTimeRange; + job.setScheduledTimeRanges({randomTimeRange}); randomlyScheduledJobs.push_back(job); } } @@ -116,6 +117,110 @@ T* randomChoice(std::vector& vec, std::mt19937& gen) { return &vec[dist(gen)]; } +bool rangesOverlap(const TimeRange& candidate, const std::vector& ranges) { + for (const auto& range : ranges) { + if (candidate.overlaps(range)) { + return true; + } + } + return false; +} + +std::vector generateSplitDurations( + time_t duration, + size_t segmentCount, + time_t minSplitDuration, + time_t granularity, + bool roundToGranularity, + std::mt19937& gen +) { + if (segmentCount <= 1) { + return {duration}; + } + + time_t unit = 1; + if (roundToGranularity && granularity > 0 && duration % granularity == 0) { + unit = granularity; + } else { + roundToGranularity = false; + } + + time_t minSplit = minSplitDuration > 0 ? minSplitDuration : 1; + if (roundToGranularity && unit > 1) { + minSplit = ((minSplit + unit - 1) / unit) * unit; + } + + if (minSplit * segmentCount > duration) { + return {}; + } + + std::vector durations(segmentCount, minSplit); + time_t remaining = duration - minSplit * segmentCount; + + if (roundToGranularity && unit > 1) { + if (remaining % unit != 0) { + return {}; + } + size_t increments = remaining / unit; + std::uniform_int_distribution dist(0, segmentCount - 1); + for (size_t i = 0; i < increments; ++i) { + durations[dist(gen)] += unit; + } + return durations; + } + + if (remaining > 0) { + std::vector cuts; + cuts.reserve(segmentCount + 1); + std::uniform_int_distribution dist(0, remaining); + cuts.push_back(0); + cuts.push_back(remaining); + for (size_t i = 0; i < segmentCount - 1; ++i) { + cuts.push_back(dist(gen)); + } + std::sort(cuts.begin(), cuts.end()); + for (size_t i = 0; i < segmentCount; ++i) { + durations[i] += (cuts[i + 1] - cuts[i]); + } + } + return durations; +} + +std::vector placeSplitSegments( + const TimeRange& schedulableTimeRange, + const std::vector& durations, + time_t granularity, + std::mt19937& gen +) { + std::vector segments; + std::vector durationsCopy = durations; + std::shuffle(durationsCopy.begin(), durationsCopy.end(), gen); + for (const auto& duration : durationsCopy) { + bool placed = false; + const int maxAttempts = 50; + for (int attempt = 0; attempt < maxAttempts; ++attempt) { + TimeRange candidate = generateRandomTimeRangeWithin( + schedulableTimeRange, + duration, + granularity, + gen + ); + if (!rangesOverlap(candidate, segments)) { + segments.push_back(candidate); + placed = true; + break; + } + } + if (!placed) { + return {}; + } + } + std::sort(segments.begin(), segments.end(), [](const TimeRange& a, const TimeRange& b) { + return a.getLow() < b.getLow(); + }); + return segments; +} + Schedule generateRandomScheduleNeighbor( Schedule s, const time_t GRANULARITY, @@ -138,6 +243,52 @@ Schedule generateRandomScheduleNeighbor( size_t chosenIndex = flexibleIndices[dist(gen)]; Job& randomFlexibleJob = jobs[chosenIndex]; + Policy policy = randomFlexibleJob.policy; + bool canSplit = policy.isSplittable() && policy.getMaxSplits() > 0; + time_t minSplitDuration = policy.getMinSplitDuration(); + time_t minSplit = minSplitDuration > 0 ? minSplitDuration : 1; + bool roundToGranularity = policy.getRoundToGranularity() + && GRANULARITY > 0 + && (randomFlexibleJob.duration % GRANULARITY == 0); + if (roundToGranularity && GRANULARITY > 1) { + minSplit = ((minSplit + GRANULARITY - 1) / GRANULARITY) * GRANULARITY; + } + size_t maxSegments = static_cast(policy.getMaxSplits()) + 1; + size_t maxSegmentsByDuration = static_cast(randomFlexibleJob.duration / minSplit); + size_t possibleSegments = std::min(maxSegments, maxSegmentsByDuration); + + bool attemptSplit = false; + if (canSplit && possibleSegments >= 2) { + std::uniform_int_distribution splitDecision(0, 1); + attemptSplit = (splitDecision(gen) == 1); + } + + if (attemptSplit) { + std::uniform_int_distribution splitCountDist(2, possibleSegments); + size_t segmentCount = splitCountDist(gen); + std::vector splitDurations = generateSplitDurations( + randomFlexibleJob.duration, + segmentCount, + minSplitDuration, + GRANULARITY, + roundToGranularity, + gen + ); + + if (!splitDurations.empty()) { + std::vector splitRanges = placeSplitSegments( + randomFlexibleJob.schedulableTimeRange, + splitDurations, + GRANULARITY, + gen + ); + if (!splitRanges.empty()) { + randomFlexibleJob.setScheduledTimeRanges(splitRanges); + return s; + } + } + } + TimeRange randomTimeRange = generateRandomTimeRangeWithin( randomFlexibleJob.schedulableTimeRange, randomFlexibleJob.duration, @@ -145,7 +296,7 @@ Schedule generateRandomScheduleNeighbor( gen ); - randomFlexibleJob.scheduledTimeRange = randomTimeRange; + randomFlexibleJob.setScheduledTimeRanges({randomTimeRange}); return s; } @@ -173,6 +324,7 @@ std::pair> scheduleJobs( for (auto& job : jobs) { if (job.isRigid()) { job.scheduledTimeRange = job.schedulableTimeRange; + job.setScheduledTimeRanges({job.scheduledTimeRange}); } } diff --git a/src/elastisched/engine/src/tag.cpp b/engine/src/tag.cpp similarity index 100% rename from src/elastisched/engine/src/tag.cpp rename to engine/src/tag.cpp diff --git a/src/elastisched/engine/src/tag.hpp b/engine/src/tag.hpp similarity index 100% rename from src/elastisched/engine/src/tag.hpp rename to engine/src/tag.hpp diff --git a/src/elastisched/engine/src/tests.hpp b/engine/src/tests.hpp similarity index 100% rename from src/elastisched/engine/src/tests.hpp rename to engine/src/tests.hpp diff --git a/src/elastisched/engine/src/utils/DependencyChecker.hpp b/engine/src/utils/DependencyChecker.hpp similarity index 76% rename from src/elastisched/engine/src/utils/DependencyChecker.hpp rename to engine/src/utils/DependencyChecker.hpp index 6efd389..566157c 100644 --- a/src/elastisched/engine/src/utils/DependencyChecker.hpp +++ b/engine/src/utils/DependencyChecker.hpp @@ -7,6 +7,7 @@ #include #include #include +#include #include "../constants.hpp" #include "../schedule.hpp" @@ -43,8 +44,22 @@ inline DependencyCheckResult checkDependencyViolations(const Schedule& schedule) } std::unordered_map jobMap; + std::unordered_map earliestStart; + std::unordered_map latestEnd; for (const auto& job : schedule.scheduledJobs) { jobMap[job.id] = &job; + time_t minStart = job.scheduledTimeRange.getLow(); + time_t maxEnd = job.scheduledTimeRange.getHigh(); + if (!job.scheduledTimeRanges.empty()) { + minStart = job.scheduledTimeRanges.front().getLow(); + maxEnd = job.scheduledTimeRanges.front().getHigh(); + for (const auto& range : job.scheduledTimeRanges) { + minStart = std::min(minStart, range.getLow()); + maxEnd = std::max(maxEnd, range.getHigh()); + } + } + earliestStart[job.id] = minStart; + latestEnd[job.id] = maxEnd; } std::unordered_map> adjList; @@ -92,15 +107,14 @@ inline DependencyCheckResult checkDependencyViolations(const Schedule& schedule) return result; } - std::unordered_set processedJobs; - for (const auto& job : schedule.scheduledJobs) { std::set violatedDeps; for (const ID& depId : job.dependencies) { - if (jobMap.find(depId) != jobMap.end() && - processedJobs.find(depId) == processedJobs.end()) { - violatedDeps.insert(depId); + if (jobMap.find(depId) != jobMap.end()) { + if (latestEnd[depId] > earliestStart[job.id]) { + violatedDeps.insert(depId); + } } } @@ -108,11 +122,9 @@ inline DependencyCheckResult checkDependencyViolations(const Schedule& schedule) result.violations.emplace_back(job.id, violatedDeps); result.hasViolations = true; } - - processedJobs.insert(job.id); } return result; } -#endif \ No newline at end of file +#endif diff --git a/src/elastisched/engine/src/utils/Interval.hpp b/engine/src/utils/Interval.hpp similarity index 65% rename from src/elastisched/engine/src/utils/Interval.hpp rename to engine/src/utils/Interval.hpp index 3f8e49e..973edb6 100644 --- a/src/elastisched/engine/src/utils/Interval.hpp +++ b/engine/src/utils/Interval.hpp @@ -1,8 +1,9 @@ #ifndef INTERVAL_H #define INTERVAL_H -#include +#include #include +#include template class Interval { @@ -36,13 +37,26 @@ class Interval { } bool overlaps(const Interval& other) const { - return !(high < other.getLow() || other.getHigh() < low); + if (low == high) { + return other.getLow() <= low && low < other.getHigh(); + } + if (other.getLow() == other.getHigh()) { + return low <= other.getLow() && other.getLow() < high; + } + return !(high <= other.getLow() || other.getHigh() <= low); } bool contains(const Interval& other) const { return low <= other.getLow() && other.getHigh() <= high; } + T overlap_length(const Interval& other) const { + if (!this->overlaps(other)) return 0; + const T start = std::max(low, other.getLow()); + const T end = std::min(high, other.getHigh()); + return end > start ? end - start : 0; + } + T length() const { return high - low; } diff --git a/src/elastisched/engine/src/utils/IntervalMap.hpp b/engine/src/utils/IntervalMap.hpp similarity index 100% rename from src/elastisched/engine/src/utils/IntervalMap.hpp rename to engine/src/utils/IntervalMap.hpp diff --git a/src/elastisched/engine/src/utils/IntervalMapReference.hpp b/engine/src/utils/IntervalMapReference.hpp similarity index 100% rename from src/elastisched/engine/src/utils/IntervalMapReference.hpp rename to engine/src/utils/IntervalMapReference.hpp diff --git a/src/elastisched/engine/src/utils/IntervalTree.hpp b/engine/src/utils/IntervalTree.hpp similarity index 83% rename from src/elastisched/engine/src/utils/IntervalTree.hpp rename to engine/src/utils/IntervalTree.hpp index 2b567ba..4c65329 100644 --- a/src/elastisched/engine/src/utils/IntervalTree.hpp +++ b/engine/src/utils/IntervalTree.hpp @@ -43,7 +43,7 @@ class IntervalTree { } bool doOverlap(Interval* i1, Interval* i2) const { - return (i1->getLow() <= i2->getHigh() && i2->getLow() <= i1->getHigh()); + return i1->overlaps(*i2); } Node* overlapSearch(Node* node, Interval& i) const { @@ -58,6 +58,21 @@ class IntervalTree { return overlapSearch(node->right.get(), i); } + + void findOverlapping(const Node* node, const Interval& key, std::vector*>& result) const { + if (!node) return; + + if (node->interval->overlaps(key)) { + result.push_back(node->interval.get()); + } + + if (node->left && node->left->max >= key.getLow()) { + findOverlapping(node->left.get(), key, result); + } + if (node->right && node->interval->getLow() <= key.getHigh()) { + findOverlapping(node->right.get(), key, result); + } + } std::unique_ptr> cloneNode(const std::unique_ptr>& node) const { if (!node) @@ -111,6 +126,12 @@ class IntervalTree { Interval* searchOverlap(T low, T high) const { return searchOverlap(Interval(low, high)); } + + std::vector*> findOverlapping(const Interval& key) const { + std::vector*> result; + findOverlapping(root.get(), key, result); + return result; + } U* searchValue(T low, T high) const { Interval query(low, high); @@ -131,4 +152,4 @@ class IntervalTree { } }; -#endif \ No newline at end of file +#endif diff --git a/frontend/css/styles.css b/frontend/css/styles.css index 758cb67..46204d7 100644 --- a/frontend/css/styles.css +++ b/frontend/css/styles.css @@ -1,5 +1,5 @@ :root { - --ink: #1b1a16; + --ink: #2c2a25; --muted: #6b675c; --paper: #f6f1e9; --cream: #efe4d6; @@ -8,6 +8,11 @@ --ocean: #3a6e7a; --rose: #c07d6b; --shadow: 0 14px 30px rgba(27, 26, 22, 0.18); + --palette-sand: #f1d7aa; + --palette-sage: #c8d6c3; + --palette-mist: #c1d4da; + --palette-clay: #e0c1b3; + --palette-moss: #d6ccb4; } * { @@ -97,6 +102,25 @@ body { animation: fadeIn 0.45s ease; } +.form-panel.floating { + position: fixed; + top: 90px; + right: 32px; + width: min(600px, 92vw); + max-height: calc(100vh - 140px); + overflow: auto; + z-index: 35; +} + +.form-panel.floating .form-header { + cursor: grab; + user-select: none; +} + +.form-panel.floating.dragging .form-header { + cursor: grabbing; +} + #settingsPanel .blob-form { grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); } @@ -106,7 +130,7 @@ body { inset: 0; display: none; place-items: center; - z-index: 20; + z-index: 40; } .modal.active { @@ -128,6 +152,106 @@ body { overflow: auto; } +#settingsPanel { + width: min(720px, 92vw); + height: min(620px, 86vh); + overflow: hidden; +} + +#settingsPanel .blob-form { + height: 100%; + overflow: auto; + padding-right: 6px; +} + +.alert-panel { + width: min(520px, 90vw); + display: grid; + gap: 16px; +} + +.alert-message { + font-size: 14px; + color: var(--ink); + line-height: 1.5; +} + +.alert-actions { + justify-content: flex-end; +} + +.alert-panel[data-action-order="confirm-alt-cancel"] .alert-action.confirm { + order: 1; +} + +.alert-panel[data-action-order="confirm-alt-cancel"] .alert-action.alt { + order: 2; +} + +.alert-panel[data-action-order="confirm-alt-cancel"] .alert-action.cancel { + order: 3; +} + +.help-panel { + width: min(640px, 92vw); +} + +.help-grid { + display: grid; + gap: 12px; + margin-top: 4px; +} + +.help-item { + display: flex; + justify-content: space-between; + gap: 16px; + padding: 10px 12px; + border-radius: 12px; + border: 1px solid rgba(27, 26, 22, 0.12); + background: rgba(255, 250, 242, 0.7); +} + +.help-action { + font-weight: 600; + color: var(--ink); +} + +.help-keys { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--muted); + font-size: 12px; +} + +.help-keys kbd { + font-family: "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", monospace; + font-size: 12px; + padding: 4px 6px; + border-radius: 8px; + border: 1px solid rgba(27, 26, 22, 0.2); + background: #fffaf2; + color: var(--ink); +} + +.alert-panel[data-action-order="cancel-alt-confirm"] .alert-action.cancel { + order: 1; +} + +.alert-panel[data-action-order="cancel-alt-confirm"] .alert-action.alt { + order: 2; +} + +.alert-panel[data-action-order="cancel-alt-confirm"] .alert-action.confirm { + order: 3; +} + +body.modal-open .info-card { + filter: blur(4px); + opacity: 0.35; +} + .form-header { display: flex; justify-content: space-between; @@ -180,6 +304,120 @@ body { grid-column: 1 / -1; } +.blob-form label > .datetime-field { + grid-column: 1 / -1; +} + +.datetime-field { + display: grid; + grid-template-columns: 1fr auto; + gap: 8px; + align-items: center; +} + +.datetime-display { + border: 1px solid #dfd2c2; + background: #fffaf2; + border-radius: 10px; + padding: 8px 10px; + font-family: inherit; + font-size: 13px; + cursor: pointer; +} + +.datetime-trigger { + padding: 6px 10px; +} + +.datetime-popover { + position: fixed; + min-width: 260px; + padding: 12px; + border-radius: 16px; + border: 1px solid #e0d3c1; + background: #fffaf2; + box-shadow: 0 18px 30px rgba(27, 26, 22, 0.2); + z-index: 45; + display: grid; + gap: 10px; +} + +.datetime-header { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 8px; +} + +.datetime-title { + font-weight: 700; + font-size: 14px; + text-align: center; +} + +.datetime-weekdays { + display: grid; + grid-template-columns: repeat(7, 1fr); + font-size: 11px; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.08em; + text-align: center; +} + +.datetime-grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 6px; +} + +.datetime-day { + border: 1px solid #e0d3c1; + background: #f7efe4; + border-radius: 10px; + padding: 6px 0; + font-size: 12px; + cursor: pointer; +} + +.datetime-day.other { + opacity: 0.4; + cursor: default; +} + +.datetime-day.active { + background: var(--ink); + color: #f7f2e8; + border-color: var(--ink); +} + +.datetime-time { + display: grid; + grid-template-columns: repeat(2, minmax(120px, 1fr)); + gap: 10px; +} + +.datetime-time label { + display: grid; + gap: 6px; + font-size: 12px; + color: var(--muted); +} + +.datetime-time select { + border: 1px solid #dfd2c2; + background: #fffaf2; + border-radius: 10px; + padding: 6px 8px; + font-family: inherit; +} + +.datetime-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + .blob-form input { border: 1px solid #dfd2c2; background: #fffaf2; @@ -338,83 +576,300 @@ body { gap: 8px; } +.tag-field.hidden { + display: none; +} + +.non-weekly-field.hidden { + display: none; +} + +.dependency-field.hidden { + display: none; +} + +.color-field.hidden { + display: none; +} + +.recurrence-extras.hidden { + display: none; +} + .tag-label { font-size: 13px; color: var(--muted); } -.tag-input-row { +.recurrence-extras { + grid-column: 1 / -1; + display: grid; + gap: 12px; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); +} + +.settings-toggle { display: flex; - gap: 8px; align-items: center; + justify-content: space-between; + gap: 12px; + padding: 6px 0; } -.tag-input-row input { - flex: 1; +.settings-toggle input[type="checkbox"] { + width: 18px; + height: 18px; } -.tag-suggestions { - display: grid; - gap: 6px; +.settings-tabs { + display: inline-flex; + gap: 8px; + padding: 8px 6px 4px; + border-radius: 999px; + background: rgba(27, 26, 22, 0.06); + margin: 0; } -.tag-suggestion { - border: 1px solid #d1c4b3; - background: #fffaf2; - color: var(--ink); - border-radius: 12px; - padding: 6px 10px; - text-align: left; - cursor: pointer; - font-size: 12px; +.settings-tabs.vertical { + flex-direction: column; + align-items: stretch; + border-radius: 18px; + padding: 10px; + width: 180px; } -.tag-list { - display: flex; - flex-wrap: wrap; - gap: 8px; +.settings-tab { + border: none; + background: transparent; + color: var(--muted); + font-weight: 600; + padding: 6px 14px; + border-radius: 999px; + cursor: pointer; + text-align: left; } -.tag-pill { - position: relative; - padding: 4px 10px; - border-radius: 999px; - border: 1px solid #e0d3c1; - background: #f8efe2; - font-size: 12px; - color: var(--ink); +.settings-tab.active { + background: var(--ink); + color: #f7f2e8; } -.tag-pill::after { - content: ""; - position: absolute; - left: 0; - right: 0; - top: 100%; - height: 8px; +.settings-section { + display: none; + gap: 14px; + grid-column: 1 / -1; } -.tag-name { - white-space: nowrap; +.settings-section.active { + display: grid; } -.tag-tooltip { - position: absolute; - top: calc(100% + 8px); - left: 0; - min-width: 200px; - max-width: 260px; - background: #fffaf2; - border: 1px solid #e0d3c1; - border-radius: 12px; - padding: 10px; - box-shadow: 0 12px 24px rgba(27, 26, 22, 0.16); - opacity: 0; - visibility: hidden; - pointer-events: none; - transition: opacity 0.15s ease, transform 0.15s ease; - transform: translateY(4px); - z-index: 30; +#settingsForm { + display: grid; + grid-template-columns: 200px 1fr; + grid-template-rows: auto 1fr auto; + gap: 12px; + align-items: start; + align-content: start; + height: 100%; +} + +#settingsForm .settings-tabs { + grid-column: 1; + grid-row: 1; + align-self: start; +} + +#settingsForm .form-actions { + grid-column: 2; + grid-row: 3; + justify-self: end; + align-self: end; +} + +#settingsForm .settings-section { + grid-column: 2; + grid-row: 1; + align-content: start; + align-items: start; +} + +#settingsForm label { + align-items: start; +} + +#settingsForm label > input, +#settingsForm label > select { + width: 100%; +} + +.color-field { + grid-column: 1 / -1; + display: grid; + gap: 10px; +} + +.color-label { + font-size: 13px; + color: var(--muted); +} + +.color-swatches { + display: inline-grid; + grid-auto-flow: column; + grid-auto-columns: 28px; + gap: 4px; + align-items: center; +} + +.color-swatch { + display: inline-grid; + place-items: center; + width: 28px; + height: 28px; + padding: 0; + position: relative; + border-radius: 4px; + border: 1px solid rgba(27, 26, 22, 0.2); + background: transparent; + cursor: pointer; + overflow: hidden; +} + +.color-swatch input { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.color-swatch .swatch { + position: absolute; + inset: 0; + border-radius: 3px; + border: none; + box-shadow: none; +} + +.color-swatch .swatch::after { + content: ""; + position: absolute; + right: 4px; + bottom: 4px; + width: 6px; + height: 6px; + border-radius: 2px; + background: rgba(27, 26, 22, 0.7); + box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.7); + opacity: 0; +} + +.color-swatch input:checked + .swatch { + box-shadow: 0 0 0 2px rgba(27, 26, 22, 0.6); +} + +.color-swatch input:checked + .swatch::after { + opacity: 1; +} + +.color-swatch .swatch-default { + background: linear-gradient(135deg, #f7d89b, #cbd8cc, #c7d7db); +} + +.color-swatch .swatch-sand { + background: var(--palette-sand); +} + +.color-swatch .swatch-sage { + background: var(--palette-sage); +} + +.color-swatch .swatch-mist { + background: var(--palette-mist); +} + +.color-swatch .swatch-clay { + background: var(--palette-clay); +} + +.color-swatch .swatch-moss { + background: var(--palette-moss); +} + +.tag-input-row { + display: flex; + gap: 8px; + align-items: center; +} + +.tag-input-row input { + flex: 1; +} + +.tag-suggestions { + display: grid; + gap: 6px; +} + +.tag-suggestion { + border: 1px solid #d1c4b3; + background: #fffaf2; + color: var(--ink); + border-radius: 12px; + padding: 6px 10px; + text-align: left; + cursor: pointer; + font-size: 12px; +} + +.tag-list { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.tag-pill { + position: relative; + display: inline-flex; + align-items: center; + padding: 4px 10px; + border-radius: 999px; + border: 1px solid #e0d3c1; + background: #f8efe2; + font-size: 12px; + color: var(--ink); + line-height: 1.2; +} + +.tag-pill::after { + content: ""; + position: absolute; + left: 0; + right: 0; + top: 100%; + height: 8px; +} + +.tag-name { + white-space: nowrap; +} + +.tag-tooltip { + position: absolute; + top: calc(100% + 8px); + left: 0; + min-width: 200px; + max-width: 260px; + background: #fffaf2; + border: 1px solid #e0d3c1; + border-radius: 12px; + padding: 10px; + box-shadow: 0 12px 24px rgba(27, 26, 22, 0.16); + opacity: 0; + visibility: hidden; + pointer-events: none; + transition: opacity 0.15s ease, transform 0.15s ease; + transform: translateY(4px); + z-index: 30; } .tag-pill:hover .tag-tooltip, @@ -494,6 +949,48 @@ body { display: none; } +.form-panel.date-mode .non-weekly-field { + display: none; +} + +.form-panel.date-mode .non-weekly-field .time-range-row { + display: none; +} + +.form-panel.date-mode .blob-type-row { + display: none; +} + +.form-panel.date-mode .non-weekly-field .slot-meta, +.form-panel.date-mode .non-weekly-field .slot-policy, +.form-panel.date-mode .non-weekly-field .slot-policy-advanced, +.form-panel.date-mode .non-weekly-field .policy-label-row { + display: none; +} + +.form-panel.date-mode .dependency-field { + display: none; +} + +.form-panel.single-mode .non-weekly-field .slot-meta { + display: none; +} + +.non-weekly-field.is-event .default-range-row, +.weekly-slot.is-event .default-range-row { + display: none; +} + +.non-weekly-field.is-event .policy-splittable, +.weekly-slot.is-event .policy-splittable { + display: none; +} + +.non-weekly-field.is-event .slot-policy-advanced, +.weekly-slot.is-event .slot-policy-advanced { + display: none; +} + .blob-form .policy-option { display: flex; align-items: center; @@ -507,6 +1004,35 @@ body { height: 18px; } +.blob-form .slot-policy-advanced label { + display: grid; + gap: 6px; + font-size: 12px; + color: var(--muted); +} + +.blob-form .slot-policy-advanced .policy-option { + display: inline-flex; + align-items: center; + gap: 8px; + margin-top: 4px; + color: var(--muted); + font-size: 13px; +} + +.blob-form .slot-policy-advanced .policy-option input[type="checkbox"] { + width: 16px; + height: 16px; +} + +.blob-form .slot-policy-advanced input[type="number"] { + font-size: 13px; +} + +.blob-form .slot-policy-advanced.is-hidden { + display: none; +} + .single-slot { grid-column: 1 / -1; } @@ -530,6 +1056,33 @@ body { height: 18px; } +.blob-type-toggle { + display: inline-flex; + padding: 2px; + border-radius: 999px; + border: 1px solid #e0d3c1; + background: #fffaf2; + gap: 2px; + width: max-content; + justify-self: start; +} + +.type-pill { + border: none; + background: transparent; + color: var(--muted); + font-weight: 600; + font-size: 12px; + padding: 4px 10px; + border-radius: 999px; + cursor: pointer; +} + +.type-pill.active { + background: var(--ink); + color: #f7f2e8; +} + .weekly-slots { grid-column: 1 / -1; display: grid; @@ -540,6 +1093,14 @@ body { display: none; } +.weekly-slots:not(.per-slot) .weekly-slot .slot-tags { + display: none; +} + +.weekly-slots:not(.per-slot) .weekly-slot .policy-label-row { + display: none; +} + .weekly-slots-header { display: flex; align-items: center; @@ -568,14 +1129,121 @@ body { grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); } +.weekly-slot-row.time-range-row { + grid-template-columns: repeat(2, minmax(180px, 1fr)); +} + +.weekly-slot-row.slot-day-row { + grid-template-columns: 1fr; +} + +.weekly-slot-row.blob-type-row { + grid-template-columns: auto; + align-items: start; +} + +.weekly-slot-row.policy-label-row { + grid-template-columns: 1fr; +} + +.policy-label { + font-size: 13px; + color: var(--muted); +} + +.slot-day-field { + display: flex; + flex-direction: column; + gap: 6px; + align-items: flex-start; +} + +.slot-day-label { + font-size: 13px; + color: var(--muted); +} + +.slot-day-toggle { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.day-pill { + width: 28px; + height: 28px; + border-radius: 999px; + border: 1px solid #e0d3c1; + background: #fffaf2; + color: var(--ink); + font-size: 12px; + font-weight: 700; + cursor: pointer; + display: grid; + place-items: center; + padding: 0; +} + +.day-pill.active { + background: var(--ink); + color: #f7f2e8; + border-color: var(--ink); +} + .weekly-slot .slot-meta { display: none; } +.weekly-slot .slot-tags { + display: none; +} + +.multiple-slots .slot-meta { + display: grid; +} + +.multiple-slots .slot-tags { + display: grid; +} + +.multiple-slots .slot-policy { + display: flex; +} + +.multiple-slots.weekly-slots:not(.per-slot) .weekly-slot .slot-policy { + display: flex; +} + +.multiple-slots .weekly-slot .slot-tags { + display: grid; +} + +.multiple-slots.weekly-slots:not(.per-slot) .weekly-slot .slot-tags { + display: grid; +} + .weekly-slots.per-slot .slot-meta { display: grid; } +.weekly-slots.per-slot .slot-tags { + display: grid; +} + +.slot-tag-field { + grid-column: 1 / -1; + display: grid; + gap: 8px; + grid-template-columns: 1fr; +} + +.slot-tag-field .tag-label, +.slot-tag-field .tag-input-row, +.slot-tag-field .tag-suggestions, +.slot-tag-field .tag-list { + grid-column: 1 / -1; +} + .weekly-slot.single-slot .slot-meta { display: grid; } @@ -593,7 +1261,7 @@ body { } .primary { - background: #1b1a16; + background: var(--ink); color: #f7f2e8; border: none; padding: 10px 18px; @@ -601,6 +1269,10 @@ body { cursor: pointer; } +.primary.danger { + background: #b4493f; +} + .form-status { font-size: 12px; color: var(--muted); @@ -650,75 +1322,228 @@ body { gap: 4px; } -.tab-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - flex-wrap: wrap; +.tab-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} + +.nav-actions { + display: inline-flex; + gap: 8px; + align-items: center; +} + +.ghost.icon { + width: 36px; + height: 36px; + display: grid; + place-items: center; + padding: 0; + font-size: 16px; +} + +.ghost.icon.settings { + margin-left: 8px; +} + +.ghost.icon.help { + font-weight: 700; +} + +.tab { + border: none; + background: transparent; + padding: 8px 18px; + border-radius: 999px; + cursor: pointer; + font-weight: 600; + color: var(--muted); +} + +.tab.active { + background: var(--ink); + color: #f7f2e8; +} + +.view-header { + margin-top: 18px; + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 12px; +} + +.date-label { + font-size: 20px; + font-weight: 700; +} + +.legend { + display: flex; + gap: 10px; + align-items: center; + font-size: 13px; + color: var(--muted); +} + +.schedule-controls { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; +} + +.now-panel { + margin-top: 16px; + padding: 14px 16px; + border-radius: 18px; + border: 1px solid #e0d3c1; + background: #fffaf2; + display: grid; + gap: 12px; + grid-template-columns: minmax(160px, 1fr) auto; + align-items: center; +} + +.now-meta { + display: grid; + gap: 4px; +} + +.now-label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted); +} + +.now-time { + font-size: 22px; + font-weight: 700; +} + +.now-date { + font-size: 12px; + color: var(--muted); +} + +.now-actions { + display: flex; + gap: 10px; + align-items: center; + justify-self: end; +} + +.add-time-menu { + position: relative; +} + +.add-time-menu::after { + content: ""; + position: absolute; + left: 0; + right: 0; + top: 100%; + height: 12px; +} + +.add-time-menu.disabled { + opacity: 0.6; + pointer-events: none; +} + +.add-time-popover { + position: absolute; + right: 0; + top: calc(100% + 8px); + min-width: 160px; + display: none; + grid-auto-rows: min-content; + gap: 6px; + padding: 10px; + border-radius: 14px; + border: 1px solid #e0d3c1; + background: #fffaf2; + box-shadow: 0 12px 24px rgba(27, 26, 22, 0.16); + z-index: 12; +} + +.add-time-menu:hover .add-time-popover, +.add-time-menu:focus-within .add-time-popover { + display: grid; } -.nav-actions { - display: inline-flex; - gap: 8px; - align-items: center; +.add-time-option { + border: 1px solid #d1c4b3; + background: #f8efe2; + color: var(--ink); + border-radius: 999px; + padding: 6px 10px; + font-size: 12px; + cursor: pointer; + text-align: left; } -.ghost.icon { - width: 36px; - height: 36px; - display: grid; - place-items: center; - padding: 0; - font-size: 16px; +.add-time-option:hover { + background: #efe2d4; } -.ghost.icon.settings { - margin-left: 8px; +.add-time-form { + display: grid; + grid-template-columns: 1fr auto; + gap: 6px; + align-items: center; } -.tab { - border: none; - background: transparent; - padding: 8px 18px; - border-radius: 999px; - cursor: pointer; - font-weight: 600; - color: var(--muted); +.add-time-form input { + border: 1px solid #dfd2c2; + background: #fffaf2; + border-radius: 10px; + padding: 6px 8px; + font-family: inherit; + font-size: 12px; } -.tab.active { - background: #1b1a16; - color: #f7f2e8; +.now-events { + grid-column: 1 / -1; + display: grid; + gap: 8px; } -.view-header { - margin-top: 18px; +.now-event { + border: 1px solid #e0d3c1; + background: #f8efe2; + color: var(--ink); + border-radius: 14px; + padding: 8px 12px; display: flex; justify-content: space-between; align-items: center; - flex-wrap: wrap; gap: 12px; + cursor: pointer; + font-size: 13px; } -.date-label { - font-size: 20px; - font-weight: 700; +.now-event.active { + background: rgba(58, 110, 122, 0.18); + color: var(--ink); + border-color: rgba(58, 110, 122, 0.45); } -.legend { - display: flex; - gap: 10px; - align-items: center; - font-size: 13px; - color: var(--muted); +.now-event-time { + font-size: 11px; + color: inherit; + opacity: 0.75; + white-space: nowrap; } -.schedule-controls { - display: flex; - flex-wrap: wrap; - gap: 10px; - align-items: center; +.now-empty { + font-size: 12px; + color: var(--muted); } .schedule-btn { @@ -812,6 +1637,35 @@ body { display: flex; align-items: center; gap: 6px; + position: relative; + padding-right: 18px; +} + +.info-close { + position: absolute; + right: 0; + top: 0; + border: none; + background: transparent; + color: #b4493f; + font-size: 14px; + cursor: pointer; + line-height: 1; + padding: 2px 6px; + border-radius: 8px; +} + +.info-close:hover { + background: rgba(180, 73, 63, 0.12); +} + +.info-star-btn { + border: none; + background: transparent; + cursor: pointer; + padding: 0; + display: inline-flex; + align-items: center; } .info-star { @@ -819,7 +1673,7 @@ body { color: rgba(27, 26, 22, 0.3); } -.info-star.active { +.info-star-btn.active .info-star { color: #f2b340; } @@ -957,6 +1811,11 @@ body { --hour-height: 44px; } +.day-column { + display: grid; + gap: 8px; +} + .hours { display: grid; grid-template-rows: repeat(24, var(--hour-height)); @@ -989,6 +1848,112 @@ body { background-size: 100% var(--hour-height); } +.all-day-row { + display: grid; + grid-template-columns: 70px 1fr; + gap: 8px; + align-items: start; +} + +.all-day-label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted); + padding-top: 4px; +} + +.full-day-events { + display: grid; + gap: 6px; +} + +.full-day-chip { + border-radius: 999px; + padding: 4px 10px; + font-size: 12px; + font-weight: 600; + color: var(--ink); + background: rgba(27, 26, 22, 0.08); + border: 1px solid rgba(27, 26, 22, 0.12); + display: flex; + align-items: center; + gap: 6px; +} + +.full-day-chip-button { + border: none; + background: transparent; + padding: 0; + cursor: pointer; + font: inherit; + color: inherit; + text-align: left; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; +} + +.full-day-chip-button span { + display: block; + overflow: hidden; + text-overflow: ellipsis; +} + +.full-day-star-toggle { + border: none; + background: transparent; + font-size: 12px; + color: rgba(27, 26, 22, 0.35); + cursor: pointer; + padding: 0; +} + +.full-day-star-toggle.active { + color: #f2b340; +} + +.full-day-chip.placeholder { + visibility: hidden; + pointer-events: none; +} + +.full-day-chip.focus { + background: #f7d89b; + border: 1px solid rgba(255, 255, 255, 0.7); +} + +.full-day-chip.deep { + background: #cbd8cc; + border: 1px solid rgba(255, 255, 255, 0.7); +} + +.full-day-chip.admin { + background: #c7d7db; + border: 1px solid rgba(255, 255, 255, 0.7); +} + +.full-day-chip.palette-sand { + background: var(--palette-sand); +} + +.full-day-chip.palette-sage { + background: var(--palette-sage); +} + +.full-day-chip.palette-mist { + background: var(--palette-mist); +} + +.full-day-chip.palette-clay { + background: var(--palette-clay); +} + +.full-day-chip.palette-moss { + background: var(--palette-moss); +} + .schedulable-overlay { position: absolute; left: 0; @@ -1006,6 +1971,23 @@ body { opacity: 1; } +.original-overlay { + position: absolute; + left: 0; + right: 0; + border-radius: 12px; + border: 1px dashed rgba(58, 110, 122, 0.35); + background: rgba(58, 110, 122, 0.1); + opacity: 0; + pointer-events: none; + transition: opacity 0.2s ease; + z-index: 9; +} + +.original-overlay.active { + opacity: 1; +} + .selection-overlay { position: absolute; left: 0; @@ -1106,6 +2088,36 @@ body { top: -10px; } +.current-time-line { + position: absolute; + left: 6px; + right: 6px; + height: 2px; + border-radius: 999px; + background: rgba(58, 110, 122, 0.95); + box-shadow: 0 6px 14px rgba(58, 110, 122, 0.25); + opacity: 0; + pointer-events: none; + z-index: 14; +} + +.current-time-line::after { + content: ""; + position: absolute; + left: -6px; + top: -5px; + width: 0; + height: 0; + border-top: 6px solid transparent; + border-bottom: 6px solid transparent; + border-left: 10px solid rgba(58, 110, 122, 0.95); + filter: drop-shadow(0 4px 8px rgba(58, 110, 122, 0.3)); +} + +.current-time-line.active { + opacity: 1; +} + .day-block { position: absolute; --col-gap: 6px; @@ -1171,6 +2183,31 @@ body { border: 1.5px solid rgba(255, 255, 255, 0.7); } +.day-block.palette-sand { + background: var(--palette-sand); + border-color: rgba(255, 255, 255, 0.7); +} + +.day-block.palette-sage { + background: var(--palette-sage); + border-color: rgba(255, 255, 255, 0.7); +} + +.day-block.palette-mist { + background: var(--palette-mist); + border-color: rgba(255, 255, 255, 0.7); +} + +.day-block.palette-clay { + background: var(--palette-clay); + border-color: rgba(255, 255, 255, 0.7); +} + +.day-block.palette-moss { + background: var(--palette-moss); + border-color: rgba(255, 255, 255, 0.7); +} + .day-block .event-time { display: block; font-size: 11px; @@ -1261,27 +2298,73 @@ body { .week-timeline { display: grid; grid-template-columns: 80px 1fr; + grid-template-rows: auto auto 1fr; gap: 12px; align-items: start; } +.week-hours-spacer { + grid-column: 1; +} + .week-hours { + grid-column: 1; + grid-row: 3; display: grid; grid-template-rows: repeat(24, var(--hour-height)); color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; - padding-top: var(--week-label-offset, 0px); box-sizing: border-box; } -.week-days { +.week-days-labels, +.week-all-day, +.week-days-tracks { display: grid; grid-template-columns: repeat(7, minmax(140px, 1fr)); gap: 12px; } +.week-days-labels { + grid-column: 2; + grid-row: 1; +} + +.week-all-day { + grid-column: 2; + grid-row: 2; +} + +.week-days-tracks { + grid-column: 2; + grid-row: 3; +} + +.week-all-day-label { + grid-column: 1; + grid-row: 2; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted); + padding-top: 4px; +} + +.week-all-day-label.empty, +.week-all-day.empty { + height: 0; + padding: 0; + margin: 0; + overflow: hidden; +} + +.week-all-day-column { + display: grid; + align-content: start; +} + .week-day-column { display: grid; gap: 8px; @@ -1482,4 +2565,20 @@ body { .day-grid { grid-template-columns: 60px 1fr; } + + .form-panel.floating { + right: 20px; + left: 20px; + width: auto; + } + + .now-panel { + grid-template-columns: 1fr; + justify-items: start; + } + + .now-actions { + justify-self: start; + flex-wrap: wrap; + } } diff --git a/frontend/index.html b/frontend/index.html index 0f146f2..1b37e58 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -18,6 +18,7 @@
+
@@ -32,26 +33,46 @@
- - - - - +
+ + +
+
+ + + +
+
+ + + + +
@@ -60,6 +81,62 @@
+ + + +
@@ -81,6 +158,7 @@ Recurrence
- Weekly slots + Weekly occurrences
@@ -129,38 +222,146 @@
Repeats annually on the default start date.
+ +
+
+
-
+
+
+ + +
+ +
+
+
+
+
+ Policy options +
-
+
Dependencies @@ -197,7 +412,7 @@
-
+
Tags
+
+ Color +
+ + + + + + +
+
+
+
+
Now
+
--:--
+
--
+
+
+ +
+ + +
+
+
+
Tuesday, May 21
diff --git a/frontend/js/actions.js b/frontend/js/actions.js new file mode 100644 index 0000000..9f74c83 --- /dev/null +++ b/frontend/js/actions.js @@ -0,0 +1,80 @@ +import { createRecurrence, deleteRecurrence, getRecurrence, updateRecurrence } from "./api.js"; +import { pushHistoryAction } from "./history.js"; + +function refreshCalendar() { + window.dispatchEvent(new CustomEvent("elastisched:refresh")); +} + +async function deleteRecurrenceWithUndo(recurrenceId) { + if (!recurrenceId) return; + const previous = await getRecurrence(recurrenceId); + await deleteRecurrence(recurrenceId); + pushHistoryAction({ + type: "delete-recurrence", + data: { + recurrenceId, + recurrenceType: previous.type, + payload: previous.payload, + restoredId: null, + }, + }); + refreshCalendar(); +} + +async function deleteOccurrenceWithUndo(blob) { + if (!blob?.recurrence_id) return; + const occurrenceStart = blob.schedulable_timerange?.start; + if (!occurrenceStart) return; + const previous = await getRecurrence(blob.recurrence_id); + const payload = previous.payload || {}; + const recurrenceType = previous.type || blob.recurrence_type || "single"; + const normalizeKey = (value) => { + if (!value) return ""; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return String(value); + return date.toISOString(); + }; + if (recurrenceType === "multiple") { + const blobs = Array.isArray(payload.blobs) ? payload.blobs : []; + const targetKey = normalizeKey(occurrenceStart); + const remaining = blobs.filter((item) => { + const itemStart = item?.schedulable_timerange?.start; + if (!itemStart) return true; + const itemKey = normalizeKey(itemStart); + return itemKey !== targetKey; + }); + if (remaining.length === 0) { + await deleteRecurrenceWithUndo(blob.recurrence_id); + return; + } + const nextPayload = { ...payload, blobs: remaining }; + await updateRecurrence(blob.recurrence_id, recurrenceType, nextPayload); + pushHistoryAction({ + type: "update-recurrence", + data: { + recurrenceId: blob.recurrence_id, + recurrenceType, + beforePayload: payload, + afterPayload: nextPayload, + }, + }); + refreshCalendar(); + return; + } + const existing = Array.isArray(payload.exclusions) ? payload.exclusions : []; + const nextExclusions = Array.from(new Set([...existing, occurrenceStart])); + const nextPayload = { ...payload, exclusions: nextExclusions }; + await updateRecurrence(blob.recurrence_id, recurrenceType, nextPayload); + pushHistoryAction({ + type: "update-recurrence", + data: { + recurrenceId: blob.recurrence_id, + recurrenceType, + beforePayload: payload, + afterPayload: nextPayload, + }, + }); + refreshCalendar(); +} + +export { deleteOccurrenceWithUndo, deleteRecurrenceWithUndo }; diff --git a/frontend/js/api.js b/frontend/js/api.js index d549c18..e75adb0 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -45,6 +45,7 @@ async function runSchedule(granularityMinutes, lookaheadSeconds) { granularity_minutes: granularityMinutes, lookahead_seconds: lookaheadSeconds, user_timezone: appConfig.userTimeZone, + include_active_occurrences: appConfig.includeActiveOccurrences, }; const response = await fetch(`${API_BASE}/schedule`, { method: "POST", @@ -65,4 +66,110 @@ async function runSchedule(granularityMinutes, lookaheadSeconds) { return response.json(); } -export { ensureOccurrences, fetchOccurrences, fetchScheduleStatus, runSchedule }; +async function getRecurrence(recurrenceId) { + const response = await fetch(`${API_BASE}/recurrences/${recurrenceId}`); + if (!response.ok) { + throw new Error("Failed to fetch recurrence"); + } + return response.json(); +} + +async function createRecurrence(type, payload) { + if (Array.isArray(type)) { + return createRecurrencesBulk(type); + } + const response = await fetch(`${API_BASE}/recurrences`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ type, payload }), + }); + if (!response.ok) { + let detail = "Failed to create recurrence"; + const contentType = response.headers.get("content-type") || ""; + if (contentType.includes("application/json")) { + const data = await response.json(); + detail = data.detail || detail; + } else { + detail = (await response.text()) || detail; + } + throw new Error(detail); + } + return response.json(); +} + +async function createRecurrencesBulk(recurrences) { + const response = await fetch(`${API_BASE}/recurrences/bulk`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(recurrences), + }); + if (!response.ok) { + let detail = "Failed to create recurrences"; + const contentType = response.headers.get("content-type") || ""; + if (contentType.includes("application/json")) { + const data = await response.json(); + detail = data.detail || detail; + } else { + detail = (await response.text()) || detail; + } + throw new Error(detail); + } + return response.json(); +} + +async function deleteRecurrence(recurrenceId) { + let response = null; + try { + response = await fetch(`${API_BASE}/recurrences/${recurrenceId}`, { + method: "DELETE", + }); + } catch (error) { + throw new Error("Failed to delete recurrence. Network error."); + } + if (!response.ok) { + if (response.status === 404) { + return; + } + let detail = "Failed to delete recurrence"; + const contentType = response.headers.get("content-type") || ""; + if (contentType.includes("application/json")) { + const data = await response.json(); + detail = data.detail || detail; + } else { + detail = (await response.text()) || detail; + } + throw new Error(detail); + } +} + +async function updateRecurrence(recurrenceId, type, payload) { + const response = await fetch(`${API_BASE}/recurrences/${recurrenceId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ type, payload }), + }); + if (!response.ok) { + let detail = "Failed to update recurrence"; + const contentType = response.headers.get("content-type") || ""; + if (contentType.includes("application/json")) { + const data = await response.json(); + detail = data.detail || detail; + } else { + detail = (await response.text()) || detail; + } + throw new Error(detail); + } + return response.json(); +} + +export { + ensureOccurrences, + fetchOccurrences, + fetchScheduleStatus, + runSchedule, + getRecurrence, + createRecurrence, + createRecurrencesBulk, + deleteRecurrence, + updateRecurrence, +}; diff --git a/frontend/js/app.js b/frontend/js/app.js index e572ff1..6535216 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -1,9 +1,35 @@ import { appConfig, isTypingInField, loadView, state } from "./core.js"; import { dom } from "./dom.js"; -import { ensureOccurrences, fetchScheduleStatus, runSchedule } from "./api.js"; -import { bindFormHandlers, openEditForm, resetFormMode, toggleForm, toggleSettings } from "./forms.js"; -import { clearInfoCardLock, setActive, startInteractiveCreate } from "./render.js"; -import { getViewRange, shiftAnchorDate } from "./utils.js"; +import { + ensureOccurrences, + fetchScheduleStatus, + getRecurrence, + runSchedule, + updateRecurrence, +} from "./api.js"; +import { pushHistoryAction, redoHistoryAction, undoHistoryAction } from "./history.js"; +import { alertDialog, bindDialogEvents, confirmDialog } from "./popups.js"; +import { + bindFormHandlers, + openCreateForm, + openEditForm, + resetFormMode, + toggleForm, + toggleSettings, + toggleHelp, +} from "./forms.js"; +import { + clearInfoCardLock, + setActive, + updateNowIndicators, +} from "./render.js"; +import { + formatTimeRangeInTimeZone, + getEffectiveOccurrenceRange, + getViewRange, + shiftAnchorDate, + toProjectIsoFromDate, +} from "./utils.js"; dom.brandTitle.textContent = appConfig.scheduleName || dom.brandTitle.textContent; dom.brandSubtitle.textContent = appConfig.subtitle || dom.brandSubtitle.textContent; @@ -12,11 +38,13 @@ bindFormHandlers(refreshView); async function refreshView(nextView = state.view) { const view = nextView || state.view; - setActive(view); + setActive(view, { deferRender: true }); const range = getViewRange(view, state.anchorDate); await ensureOccurrences(range.start, range.end); setActive(view); await refreshScheduleStatus(); + renderNowPanel(); + updateNowIndicators(); } function setScheduleStatusState(mode, message) { @@ -67,6 +95,225 @@ async function handleRunSchedule() { } } +function getOccurrenceRange(blob) { + const effective = getEffectiveOccurrenceRange(blob); + if (!effective) return null; + return { start: effective.start, end: effective.effectiveEnd }; +} + +function getCurrentOccurrences() { + const now = new Date(); + return state.blobs + .map((blob) => { + const range = getOccurrenceRange(blob); + if (!range) return null; + if (now < range.start || now >= range.end) return null; + return { blob, range }; + }) + .filter(Boolean) + .sort((a, b) => a.range.start - b.range.start); +} + +function ensureSelectedOccurrence(current) { + if (!current.length) { + state.currentOccurrenceId = null; + return; + } + const exists = current.some((item) => item.blob.id === state.currentOccurrenceId); + if (!exists) { + state.currentOccurrenceId = current[0].blob.id; + } +} + +function renderNowPanel() { + if (!dom.nowPanel || !dom.nowTime || !dom.nowDate || !dom.nowEvents) return; + const now = new Date(); + const timeFormatter = new Intl.DateTimeFormat(undefined, { + timeZone: appConfig.userTimeZone, + hour: "2-digit", + minute: "2-digit", + }); + const dateFormatter = new Intl.DateTimeFormat(undefined, { + timeZone: appConfig.userTimeZone, + weekday: "short", + month: "short", + day: "numeric", + }); + dom.nowTime.textContent = timeFormatter.format(now); + dom.nowDate.textContent = dateFormatter.format(now); + + const current = getCurrentOccurrences(); + ensureSelectedOccurrence(current); + const activeId = state.currentOccurrenceId; + + if (!current.length) { + dom.nowEvents.innerHTML = `
No active events.
`; + if (dom.finishNowBtn) dom.finishNowBtn.disabled = true; + if (dom.addTimeBtn) dom.addTimeBtn.disabled = true; + const addMenu = dom.addTimeBtn?.closest(".add-time-menu"); + if (addMenu) addMenu.classList.add("disabled"); + return; + } + + const eventHtml = current + .map(({ blob }) => { + const timeZone = blob?.tz || appConfig.userTimeZone; + const effectiveRange = getEffectiveOccurrenceRange(blob); + if (!effectiveRange) return ""; + const timeLabel = formatTimeRangeInTimeZone( + effectiveRange.start, + effectiveRange.effectiveEnd, + timeZone + ); + return ` + + `; + }) + .join(""); + dom.nowEvents.innerHTML = eventHtml; + if (dom.finishNowBtn) dom.finishNowBtn.disabled = false; + if (dom.addTimeBtn) dom.addTimeBtn.disabled = false; + const addMenu = dom.addTimeBtn?.closest(".add-time-menu"); + if (addMenu) addMenu.classList.remove("disabled"); +} + +function getSelectedOccurrence() { + if (!state.currentOccurrenceId) return null; + return state.blobs.find((blob) => blob.id === state.currentOccurrenceId) || null; +} + +async function extendOccurrenceByMinutes(minutes) { + const blob = getSelectedOccurrence(); + if (!blob) return; + if (!Number.isFinite(minutes) || minutes <= 0) return; + const effective = getEffectiveOccurrenceRange(blob); + if (!effective) return; + const schedEnd = new Date(blob.schedulable_timerange?.end); + if (Number.isNaN(schedEnd.getTime())) return; + const occurrenceKey = blob.schedulable_timerange?.start; + if (!occurrenceKey) return; + let previous = null; + try { + previous = await getRecurrence(blob.recurrence_id); + } catch (error) { + await alertDialog(error?.message || "Unable to load recurrence."); + return; + } + const payload = previous.payload || {}; + const overrides = + payload.occurrence_overrides && typeof payload.occurrence_overrides === "object" + ? { ...payload.occurrence_overrides } + : {}; + const currentOverride = overrides[occurrenceKey] || {}; + let currentAdded = Number(currentOverride?.added_minutes || 0); + if (!Number.isFinite(currentAdded)) currentAdded = 0; + const nextAdded = currentAdded + minutes; + const nextEnd = new Date(effective.end.getTime() + nextAdded * 60000); + let schedulableEndOverride = null; + if (nextEnd > schedEnd) { + const confirmed = await confirmDialog( + "This extends beyond the schedulable window. Continue anyway?", + { confirmText: "Extend", cancelText: "Cancel" } + ); + if (!confirmed) return; + schedulableEndOverride = nextEnd; + } + const nextOverride = { + ...(currentOverride || {}), + added_minutes: nextAdded, + }; + if (schedulableEndOverride) { + nextOverride.schedulable_timerange = { + start: blob.schedulable_timerange?.start, + end: toProjectIsoFromDate(schedulableEndOverride, appConfig.projectTimeZone), + }; + } + if (nextOverride.finished_at) { + delete nextOverride.finished_at; + } + overrides[occurrenceKey] = nextOverride; + const nextPayload = { ...payload, occurrence_overrides: overrides }; + + try { + await updateRecurrence( + blob.recurrence_id, + blob.recurrence_type || previous.type || "single", + nextPayload + ); + pushHistoryAction({ + type: "update-recurrence", + data: { + recurrenceId: blob.recurrence_id, + recurrenceType: blob.recurrence_type || previous.type || "single", + beforePayload: payload, + afterPayload: nextPayload, + }, + }); + state.loadedRange = null; + await refreshView(state.view); + } catch (error) { + await alertDialog(error?.message || "Failed to update occurrence."); + } +} + +async function handleFinishNow() { + const blob = getSelectedOccurrence(); + if (!blob) return; + const effective = getEffectiveOccurrenceRange(blob); + if (!effective) return; + const bufferMinutes = Math.max(1, Number(appConfig.finishEarlyBufferMinutes || 15)); + const threshold = new Date( + effective.effectiveEnd.getTime() - bufferMinutes * 60000 + ); + const now = new Date(); + const occurrenceKey = blob.schedulable_timerange?.start; + if (!occurrenceKey) return; + let previous = null; + try { + previous = await getRecurrence(blob.recurrence_id); + } catch (error) { + await alertDialog(error?.message || "Unable to load recurrence."); + return; + } + const payload = previous.payload || {}; + const overrides = + payload.occurrence_overrides && typeof payload.occurrence_overrides === "object" + ? { ...payload.occurrence_overrides } + : {}; + const currentOverride = overrides[occurrenceKey] || {}; + overrides[occurrenceKey] = { + ...(currentOverride || {}), + finished_at: toProjectIsoFromDate(now, appConfig.projectTimeZone), + }; + const nextPayload = { ...payload, occurrence_overrides: overrides }; + try { + await updateRecurrence( + blob.recurrence_id, + blob.recurrence_type || previous.type || "single", + nextPayload + ); + pushHistoryAction({ + type: "update-recurrence", + data: { + recurrenceId: blob.recurrence_id, + recurrenceType: blob.recurrence_type || previous.type || "single", + beforePayload: payload, + afterPayload: nextPayload, + }, + }); + state.loadedRange = null; + await refreshView(state.view); + if (now < threshold) { + await handleRunSchedule(); + } + } catch (error) { + await alertDialog(error?.message || "Failed to finish occurrence."); + } +} + dom.tabs.forEach((tab) => { tab.addEventListener("click", () => refreshView(tab.dataset.view)); }); @@ -75,8 +322,46 @@ if (dom.runScheduleBtn) { dom.runScheduleBtn.addEventListener("click", handleRunSchedule); } +if (dom.nowEvents) { + dom.nowEvents.addEventListener("click", (event) => { + const target = event.target.closest("[data-occurrence-id]"); + if (!target) return; + const occurrenceId = target.getAttribute("data-occurrence-id"); + if (!occurrenceId) return; + state.currentOccurrenceId = occurrenceId; + renderNowPanel(); + }); +} + +if (dom.finishNowBtn) { + dom.finishNowBtn.addEventListener("click", () => { + handleFinishNow(); + }); +} + +if (dom.addTimePopover) { + dom.addTimePopover.addEventListener("click", (event) => { + const button = event.target.closest("[data-add-minutes]"); + if (!button) return; + const minutes = Number(button.getAttribute("data-add-minutes")); + if (!Number.isFinite(minutes)) return; + extendOccurrenceByMinutes(minutes); + }); +} + +if (dom.addTimeForm) { + dom.addTimeForm.addEventListener("submit", (event) => { + event.preventDefault(); + const formData = new FormData(dom.addTimeForm); + const minutes = Number(formData.get("addMinutes") || 0); + if (!Number.isFinite(minutes) || minutes <= 0) return; + extendOccurrenceByMinutes(minutes); + dom.addTimeForm.reset(); + }); +} + document.addEventListener("click", (event) => { - const target = event.target.closest("[data-date]"); + const target = event.target.closest(".week-day-label button, .month-day, .year-month"); if (!target) return; const dateIso = target.getAttribute("data-date"); if (!dateIso) return; @@ -107,19 +392,26 @@ document.addEventListener("click", async (event) => { }, 1200); }); -document.addEventListener("contextmenu", (event) => { - const target = event.target.closest("[data-blob-id]"); - if (!target) return; - if (dom.formPanel.classList.contains("active")) { - return; - } - event.preventDefault(); - const blobId = target.getAttribute("data-blob-id"); - const blob = state.blobs.find((item) => item.id === blobId); - if (blob) { - openEditForm(blob); - } -}); +document.addEventListener( + "click", + (event) => { + if (!event.shiftKey) return; + const target = event.target.closest("[data-blob-id]"); + if (!target) return; + if (event.target.closest(".star-toggle")) return; + if (dom.formPanel.classList.contains("active")) { + return; + } + event.preventDefault(); + event.stopPropagation(); + const blobId = target.getAttribute("data-blob-id"); + const blob = state.blobs.find((item) => item.id === blobId); + if (blob) { + openEditForm(blob); + } + }, + true +); document.addEventListener("click", (event) => { if (!dom.formPanel.classList.contains("active")) return; @@ -131,6 +423,21 @@ document.addEventListener("click", (event) => { window.addEventListener("keydown", (event) => { if (isTypingInField(event.target)) return; + const hasMod = event.ctrlKey || event.metaKey; + if (hasMod && event.key.toLowerCase() === "z") { + event.preventDefault(); + if (event.shiftKey) { + redoHistoryAction(); + } else { + undoHistoryAction(); + } + return; + } + if (hasMod && event.key.toLowerCase() === "y") { + event.preventDefault(); + redoHistoryAction(); + return; + } const isArrowLeft = event.key === "ArrowLeft" || event.key === "Left" || @@ -147,17 +454,22 @@ window.addEventListener("keydown", (event) => { toggleSettings(false); dom.settingsStatus.textContent = ""; } + if (dom.helpModal?.classList.contains("active")) { + toggleHelp(false); + } if (dom.formPanel.classList.contains("active")) { toggleForm(false); resetFormMode(); } return; } - if (event.key.toLowerCase() === "n") { + if (!hasMod && event.key.toLowerCase() === "n") { + event.preventDefault(); + openCreateForm("task"); + } + if (!hasMod && event.key.toLowerCase() === "c") { event.preventDefault(); - resetFormMode(); - toggleForm(true); - startInteractiveCreate(); + openCreateForm("event"); } if (isArrowLeft || isArrowRight) { const direction = isArrowLeft ? -1 : 1; @@ -173,5 +485,17 @@ window.addEventListener("keydown", (event) => { }); resetFormMode(); +bindDialogEvents(); +window.elastischedRefresh = () => { + state.loadedRange = null; + refreshView(state.view); +}; +window.addEventListener("elastisched:refresh", () => { + window.elastischedRefresh?.(); +}); const savedView = loadView(); refreshView(savedView || "day"); +window.setInterval(() => { + renderNowPanel(); + updateNowIndicators(); +}, 30000); diff --git a/frontend/js/constants.js b/frontend/js/constants.js index b110c0b..df2f0c6 100644 --- a/frontend/js/constants.js +++ b/frontend/js/constants.js @@ -2,6 +2,8 @@ window.APP_CONFIG = { scheduleName: "Chris' Schedule", subtitle: "Elastisched", minuteGranularity: 5, + finishEarlyBufferMinutes: 15, + includeActiveOccurrences: true, lookaheadSeconds: 14 * 24 * 60 * 60, projectTimeZone: "UTC", }; diff --git a/frontend/js/core.js b/frontend/js/core.js index a95c9db..4ed9149 100644 --- a/frontend/js/core.js +++ b/frontend/js/core.js @@ -18,15 +18,25 @@ const state = { activeBlockClickHandler: null, infoCardLocked: false, lockedBlobId: null, + currentBlobType: "task", scheduleDirty: true, scheduleLastRun: null, scheduleRunning: false, + currentOccurrenceId: null, }; const defaultConfig = { scheduleName: window.APP_CONFIG?.scheduleName || "Elastisched", subtitle: window.APP_CONFIG?.subtitle || "Schedule at a glance", minuteGranularity: Math.max(1, Number(window.APP_CONFIG?.minuteGranularity || 5)), + finishEarlyBufferMinutes: Math.max( + 1, + Number(window.APP_CONFIG?.finishEarlyBufferMinutes || 15) + ), + includeActiveOccurrences: + typeof window.APP_CONFIG?.includeActiveOccurrences === "boolean" + ? window.APP_CONFIG.includeActiveOccurrences + : true, projectTimeZone: window.APP_CONFIG?.projectTimeZone || "UTC", lookaheadSeconds: Math.max( 1, diff --git a/frontend/js/datetime_picker.js b/frontend/js/datetime_picker.js new file mode 100644 index 0000000..f45244a --- /dev/null +++ b/frontend/js/datetime_picker.js @@ -0,0 +1,449 @@ +import { minuteGranularity } from "./core.js"; + +let activeField = null; +let activePopover = null; +let activeState = null; + +function pad(value) { + return `${value}`.padStart(2, "0"); +} + +function parseLocalValue(value) { + if (!value) return null; + const [datePart, timePart] = value.split("T"); + if (!datePart || !timePart) return null; + const [year, month, day] = datePart.split("-").map(Number); + const [hour, minute] = timePart.split(":").map(Number); + if ([year, month, day, hour, minute].some((item) => Number.isNaN(item))) { + return null; + } + return { year, month, day, hour, minute }; +} + +function parseDateValue(value) { + if (!value) return null; + const [year, month, day] = value.split("-").map(Number); + if ([year, month, day].some((item) => Number.isNaN(item))) { + return null; + } + return { year, month, day }; +} + +function formatLocalValue({ year, month, day, hour, minute }) { + return `${year}-${pad(month)}-${pad(day)}T${pad(hour)}:${pad(minute)}`; +} + +function normalizeDragValue(value, sourceMode, targetMode) { + if (!value) return ""; + if (targetMode === "date") { + return value.split("T")[0] || ""; + } + if (sourceMode === "date") { + const datePart = value.split("T")[0] || ""; + return datePart ? `${datePart}T00:00` : ""; + } + return value; +} + +function formatDisplay(value, mode) { + if (mode === "date") { + const parsed = parseDateValue(value); + if (!parsed) return ""; + const date = new Date(parsed.year, parsed.month - 1, parsed.day); + if (Number.isNaN(date.getTime())) return ""; + return date.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + }); + } + const parsed = parseLocalValue(value); + if (!parsed) return ""; + const date = new Date( + parsed.year, + parsed.month - 1, + parsed.day, + parsed.hour, + parsed.minute + ); + if (Number.isNaN(date.getTime())) return ""; + return date.toLocaleString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +function getMonthLabel(year, monthIndex) { + const date = new Date(year, monthIndex, 1); + return date.toLocaleDateString(undefined, { month: "long", year: "numeric" }); +} + +function getDaysForMonth(year, monthIndex) { + const start = new Date(year, monthIndex, 1); + const startDay = start.getDay(); + const daysInMonth = new Date(year, monthIndex + 1, 0).getDate(); + const prevMonthDays = new Date(year, monthIndex, 0).getDate(); + const days = []; + for (let i = startDay - 1; i >= 0; i -= 1) { + days.push({ day: prevMonthDays - i, isOther: true }); + } + for (let i = 1; i <= daysInMonth; i += 1) { + days.push({ day: i, isOther: false }); + } + while (days.length % 7 !== 0) { + days.push({ day: days.length % 7 + 1, isOther: true }); + } + return days; +} + +function buildPopover(mode) { + const popover = document.createElement("div"); + popover.className = "datetime-popover"; + popover.innerHTML = ` +
+ +
+ +
+
+ ${["S", "M", "T", "W", "T", "F", "S"].map((day) => `${day}`).join("")} +
+
+ ${ + mode === "date" + ? "" + : ` +
+ + +
+ ` + } +
+ + + +
+ `; + return popover; +} + +function closePopover() { + if (activePopover) { + activePopover.remove(); + } + activePopover = null; + activeField = null; + activeState = null; +} + +function updateDisplayForField(field) { + const display = field.querySelector(".datetime-display"); + const hidden = field.querySelector("[data-datetime-input]"); + if (!display || !hidden) return; + const mode = field.dataset.mode || "datetime"; + display.value = formatDisplay(hidden.value, mode); +} + +function renderPopover() { + if (!activePopover || !activeState) return; + const title = activePopover.querySelector(".datetime-title"); + const grid = activePopover.querySelector(".datetime-grid"); + const hourSelect = activePopover.querySelector('[data-action="hour"]'); + const minuteSelect = activePopover.querySelector('[data-action="minute"]'); + + title.textContent = getMonthLabel(activeState.year, activeState.monthIndex); + grid.innerHTML = ""; + const days = getDaysForMonth(activeState.year, activeState.monthIndex); + days.forEach((item) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = `datetime-day ${item.isOther ? "other" : ""}`; + button.textContent = item.day; + if (!item.isOther && item.day === activeState.day) { + button.classList.add("active"); + } + button.addEventListener("click", () => { + if (item.isOther) return; + activeState.day = item.day; + renderPopover(); + }); + grid.appendChild(button); + }); + + if (activeState.mode !== "date" && hourSelect && minuteSelect) { + hourSelect.innerHTML = ""; + for (let hour = 0; hour < 24; hour += 1) { + const option = document.createElement("option"); + option.value = `${hour}`; + option.textContent = pad(hour); + if (hour === activeState.hour) { + option.selected = true; + } + hourSelect.appendChild(option); + } + + minuteSelect.innerHTML = ""; + const step = Math.max(1, minuteGranularity); + for (let minute = 0; minute < 60; minute += step) { + const option = document.createElement("option"); + option.value = `${minute}`; + option.textContent = pad(minute); + if (minute === activeState.minute) { + option.selected = true; + } + minuteSelect.appendChild(option); + } + } +} + +function setPopoverPosition(field, popover) { + const rect = field.getBoundingClientRect(); + const padding = 12; + let left = rect.left; + let top = rect.bottom + 8; + if (left + popover.offsetWidth > window.innerWidth - padding) { + left = window.innerWidth - popover.offsetWidth - padding; + } + left = Math.max(padding, left); + if (top + popover.offsetHeight > window.innerHeight - padding) { + top = rect.top - popover.offsetHeight - 8; + } + top = Math.max(padding, top); + popover.style.left = `${left}px`; + popover.style.top = `${top}px`; +} + +function openPopover(field) { + closePopover(); + const hidden = field.querySelector("[data-datetime-input]"); + if (!hidden) return; + const mode = field.dataset.mode || "datetime"; + const parsed = mode === "date" ? parseDateValue(hidden.value) : parseLocalValue(hidden.value); + const now = new Date(); + const initial = parsed || { + year: now.getFullYear(), + month: now.getMonth() + 1, + day: now.getDate(), + hour: now.getHours(), + minute: now.getMinutes() - (now.getMinutes() % Math.max(1, minuteGranularity)), + }; + activeState = { + year: initial.year, + monthIndex: initial.month - 1, + day: initial.day, + hour: mode === "date" ? 0 : initial.hour, + minute: mode === "date" ? 0 : initial.minute, + mode, + }; + activeField = field; + activePopover = buildPopover(mode); + document.body.appendChild(activePopover); + activePopover.addEventListener("click", (event) => { + event.stopPropagation(); + }); + activePopover.addEventListener("click", handlePopoverClick); + const updateTime = (event) => { + if (!activePopover || !activeState) return; + const action = event.target.closest("[data-action]")?.dataset?.action; + if (action === "hour") { + activeState.hour = Number(event.target.value); + } else if (action === "minute") { + activeState.minute = Number(event.target.value); + } + }; + if (mode !== "date") { + activePopover.addEventListener("input", updateTime); + activePopover.addEventListener("change", updateTime); + } + renderPopover(); + setPopoverPosition(field, activePopover); +} + +function applySelection() { + if (!activeField || !activeState) return; + const hidden = activeField.querySelector("[data-datetime-input]"); + if (!hidden) return; + if (activeState.mode === "date") { + hidden.value = `${activeState.year}-${pad(activeState.monthIndex + 1)}-${pad( + activeState.day + )}`; + } else { + hidden.value = formatLocalValue({ + year: activeState.year, + month: activeState.monthIndex + 1, + day: activeState.day, + hour: activeState.hour, + minute: activeState.minute, + }); + } + hidden.dispatchEvent(new Event("change", { bubbles: true })); + updateDisplayForField(activeField); + closePopover(); +} + +function clearSelection() { + if (!activeField) return; + const hidden = activeField.querySelector("[data-datetime-input]"); + if (!hidden) return; + hidden.value = ""; + hidden.dispatchEvent(new Event("change", { bubbles: true })); + updateDisplayForField(activeField); + closePopover(); +} + +function handlePopoverClick(event) { + if (!activePopover || !activeState) return; + const action = event.target.closest("[data-action]")?.dataset?.action; + if (!action) return; + event.preventDefault(); + event.stopPropagation(); + if (action === "prev-month") { + activeState.monthIndex -= 1; + if (activeState.monthIndex < 0) { + activeState.monthIndex = 11; + activeState.year -= 1; + } + const maxDay = new Date(activeState.year, activeState.monthIndex + 1, 0).getDate(); + if (activeState.day > maxDay) { + activeState.day = maxDay; + } + renderPopover(); + return; + } + if (action === "next-month") { + activeState.monthIndex += 1; + if (activeState.monthIndex > 11) { + activeState.monthIndex = 0; + activeState.year += 1; + } + const maxDay = new Date(activeState.year, activeState.monthIndex + 1, 0).getDate(); + if (activeState.day > maxDay) { + activeState.day = maxDay; + } + renderPopover(); + return; + } + if (action === "today") { + const now = new Date(); + activeState.year = now.getFullYear(); + activeState.monthIndex = now.getMonth(); + activeState.day = now.getDate(); + activeState.hour = now.getHours(); + activeState.minute = now.getMinutes() - (now.getMinutes() % Math.max(1, minuteGranularity)); + renderPopover(); + return; + } + if (action === "clear") { + clearSelection(); + return; + } + if (action === "apply") { + applySelection(); + } +} + +function bindPopoverEvents() { + document.addEventListener("click", (event) => { + if (!activePopover) return; + if (event.target.closest(".datetime-popover")) return; + if (event.target.closest(".datetime-field")) return; + closePopover(); + }); + window.addEventListener("keydown", (event) => { + if (!activePopover) return; + if (event.key === "Escape") { + closePopover(); + } + }); + window.addEventListener("resize", () => { + if (activePopover && activeField) { + setPopoverPosition(activeField, activePopover); + } + }); +} + +function bindDateTimePickers() { + const fields = document.querySelectorAll(".datetime-field"); + fields.forEach((field) => { + const display = field.querySelector(".datetime-display"); + const trigger = field.querySelector(".datetime-trigger"); + const hidden = field.querySelector("[data-datetime-input]"); + if (!display || !hidden) return; + updateDisplayForField(field); + if (field.dataset.datetimeBound) { + return; + } + field.dataset.datetimeBound = "true"; + const open = (event) => { + event.preventDefault(); + openPopover(field); + }; + display.addEventListener("click", open); + if (trigger) { + trigger.addEventListener("click", open); + } + hidden.addEventListener("change", () => updateDisplayForField(field)); + display.setAttribute("draggable", "true"); + display.addEventListener("dragstart", (event) => { + if (!hidden.value) { + event.preventDefault(); + return; + } + const payload = { + value: hidden.value, + mode: field.dataset.mode || "datetime", + }; + event.dataTransfer?.setData("application/x-elastisched-datetime", JSON.stringify(payload)); + event.dataTransfer?.setData("text/plain", hidden.value); + }); + const handleDragOver = (event) => { + if (event.dataTransfer?.types?.includes("application/x-elastisched-datetime")) { + event.preventDefault(); + } + }; + const handleDrop = (event) => { + const data = event.dataTransfer?.getData("application/x-elastisched-datetime"); + if (!data) return; + event.preventDefault(); + let payload = null; + try { + payload = JSON.parse(data); + } catch { + payload = null; + } + if (!payload?.value) return; + const targetMode = field.dataset.mode || "datetime"; + const normalized = normalizeDragValue(payload.value, payload.mode, targetMode); + if (!normalized) return; + hidden.value = normalized; + hidden.dispatchEvent(new Event("change", { bubbles: true })); + updateDisplayForField(field); + }; + display.addEventListener("dragover", handleDragOver); + display.addEventListener("drop", handleDrop); + field.addEventListener("dragover", handleDragOver); + field.addEventListener("drop", handleDrop); + }); + if (!document.body.dataset.datetimePickerBound) { + document.body.dataset.datetimePickerBound = "true"; + bindPopoverEvents(); + } +} + +function syncDateTimeDisplays() { + document.querySelectorAll(".datetime-field").forEach((field) => { + updateDisplayForField(field); + }); +} + +export { bindDateTimePickers, syncDateTimeDisplays }; diff --git a/frontend/js/dom.js b/frontend/js/dom.js index c6c4ab6..25a5850 100644 --- a/frontend/js/dom.js +++ b/frontend/js/dom.js @@ -16,17 +16,35 @@ const dom = { formSubmitBtn: document.getElementById("formSubmitBtn"), recurrenceType: document.getElementById("recurrenceType"), recurrenceSummary: document.getElementById("recurrenceSummary"), + recurrenceEnd: document.querySelector('[name="recurrenceEnd"]'), + blobTypeInput: document.getElementById("blobTypeInput"), weeklyPerSlot: document.getElementById("weeklyPerSlot"), weeklySlots: document.getElementById("weeklySlots"), weeklySlotStatus: document.getElementById("weeklySlotStatus"), addWeeklySlotBtn: document.getElementById("addWeeklySlotBtn"), + multipleSlots: document.getElementById("multipleSlots"), + multipleSlotStatus: document.getElementById("multipleSlotStatus"), + addMultipleSlotBtn: document.getElementById("addMultipleSlotBtn"), settingsBtn: document.getElementById("settingsBtn"), + helpBtn: document.getElementById("helpBtn"), settingsPanel: document.getElementById("settingsPanel"), settingsModal: document.getElementById("settingsModal"), settingsBackdrop: document.getElementById("settingsBackdrop"), closeSettingsBtn: document.getElementById("closeSettingsBtn"), + helpPanel: document.getElementById("helpPanel"), + helpModal: document.getElementById("helpModal"), + helpBackdrop: document.getElementById("helpBackdrop"), + closeHelpBtn: document.getElementById("closeHelpBtn"), settingsForm: document.getElementById("settingsForm"), settingsStatus: document.getElementById("settingsStatus"), + alertModal: document.getElementById("alertModal"), + alertBackdrop: document.getElementById("alertBackdrop"), + alertPanel: document.getElementById("alertPanel"), + alertTitle: document.getElementById("alertTitle"), + alertMessage: document.getElementById("alertMessage"), + alertCancelBtn: document.getElementById("alertCancelBtn"), + alertAltBtn: document.getElementById("alertAltBtn"), + alertConfirmBtn: document.getElementById("alertConfirmBtn"), prevDayBtn: document.getElementById("prevDayBtn"), nextDayBtn: document.getElementById("nextDayBtn"), goTodayBtn: document.getElementById("goTodayBtn"), @@ -36,6 +54,14 @@ const dom = { brandTitle: document.getElementById("brandTitle"), brandSubtitle: document.getElementById("brandSubtitle"), infoCard: document.getElementById("infoCard"), + nowPanel: document.getElementById("nowPanel"), + nowTime: document.getElementById("nowTime"), + nowDate: document.getElementById("nowDate"), + nowEvents: document.getElementById("nowEvents"), + finishNowBtn: document.getElementById("finishNowBtn"), + addTimeBtn: document.getElementById("addTimeBtn"), + addTimePopover: document.getElementById("addTimePopover"), + addTimeForm: document.getElementById("addTimeForm"), deleteRecurrenceBtn: document.getElementById("deleteRecurrenceBtn"), deleteOccurrenceBtn: document.getElementById("deleteOccurrenceBtn"), starRecurrenceBtn: document.getElementById("starRecurrenceBtn"), @@ -44,6 +70,7 @@ const dom = { addDependencyBtn: document.getElementById("addDependencyBtn"), dependencyList: document.getElementById("dependencyList"), dependencySuggestions: document.getElementById("dependencySuggestions"), + recurrenceTagField: document.getElementById("recurrenceTagField"), tagInput: document.querySelector('[name="blobTagInput"]'), addTagBtn: document.getElementById("addTagBtn"), tagList: document.getElementById("tagList"), diff --git a/frontend/js/forms.js b/frontend/js/forms.js index cabb4ba..038f201 100644 --- a/frontend/js/forms.js +++ b/frontend/js/forms.js @@ -9,13 +9,63 @@ import { toProjectIsoFromLocalInput, } from "./utils.js"; import { startInteractiveCreate } from "./render.js"; +import { createRecurrence, updateRecurrence } from "./api.js"; +import { confirmDialog } from "./popups.js"; +import { bindDateTimePickers, syncDateTimeDisplays } from "./datetime_picker.js"; +import { deleteOccurrenceWithUndo, deleteRecurrenceWithUndo } from "./actions.js"; let refreshView = null; const recurrenceFieldGroups = document.querySelectorAll(".recurrence-fields"); +const weeklyRecurrenceFields = document.querySelector('.recurrence-fields[data-recurrence="weekly"]'); const WEEK_DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; const editOnlyElements = document.querySelectorAll(".edit-only"); +const settingsTabs = document.querySelectorAll(".settings-tab"); +const settingsSections = document.querySelectorAll(".settings-section"); +const nonWeeklyField = document.querySelector(".non-weekly-field"); +const primaryDependencyField = document.querySelector( + ".dependency-field:not(.slot-dependency-field)" +); +const primaryTagField = document.getElementById("recurrenceTagField"); let dependencyIds = []; let tagNames = []; +const slotTagStore = new WeakMap(); +const slotDependencyStore = new WeakMap(); +let isDraggingForm = false; +let dragOffset = { x: 0, y: 0 }; +let formPosition = null; + +const weeklyFieldPlacement = { + dependency: { + el: primaryDependencyField, + parent: null, + nextSibling: null, + }, + tag: { + el: primaryTagField, + parent: null, + nextSibling: null, + }, +}; + +function storeWeeklyFieldPlacement(entry) { + if (!entry?.el || entry.parent) return; + entry.parent = entry.el.parentNode; + entry.nextSibling = entry.el.nextSibling; +} + +function moveWeeklyField(entry, target) { + if (!entry?.el || !target) return; + target.appendChild(entry.el); +} + +function restoreWeeklyField(entry) { + if (!entry?.el || !entry.parent) return; + if (entry.nextSibling && entry.nextSibling.parentNode === entry.parent) { + entry.parent.insertBefore(entry.el, entry.nextSibling); + } else { + entry.parent.appendChild(entry.el); + } +} function setRefreshHandler(handler) { refreshView = handler; @@ -24,6 +74,12 @@ function setRefreshHandler(handler) { function toggleForm(show) { const isActive = typeof show === "boolean" ? show : !dom.formPanel.classList.contains("active"); dom.formPanel.classList.toggle("active", isActive); + dom.formPanel.classList.toggle("floating", isActive); + if (isActive && formPosition) { + dom.formPanel.style.left = `${formPosition.x}px`; + dom.formPanel.style.top = `${formPosition.y}px`; + dom.formPanel.style.right = "auto"; + } } function toggleSettings(show) { @@ -31,14 +87,69 @@ function toggleSettings(show) { dom.settingsModal.classList.toggle("active", isActive); dom.settingsPanel.classList.toggle("active", isActive); dom.settingsModal.setAttribute("aria-hidden", (!isActive).toString()); + if (isActive) { + setActiveSettingsTab(settingsTabs[0]?.dataset?.settingsTab || "general"); + } +} + +function toggleHelp(show) { + const isActive = typeof show === "boolean" ? show : !dom.helpModal.classList.contains("active"); + dom.helpModal.classList.toggle("active", isActive); + dom.helpPanel.classList.toggle("active", isActive); + dom.helpModal.setAttribute("aria-hidden", (!isActive).toString()); +} + +function setActiveSettingsTab(tabName) { + if (!tabName) return; + settingsTabs.forEach((tab) => { + const isActive = tab.dataset.settingsTab === tabName; + tab.classList.toggle("active", isActive); + tab.setAttribute("aria-selected", isActive.toString()); + }); + settingsSections.forEach((section) => { + section.classList.toggle("active", section.dataset.settingsSection === tabName); + }); +} + +function populateTimeZones() { + const select = dom.settingsForm?.userTimeZone; + if (!select || select.dataset.populated === "true") return; + let zones = []; + if (typeof Intl !== "undefined" && typeof Intl.supportedValuesOf === "function") { + zones = Intl.supportedValuesOf("timeZone"); + } + if (!zones.length) { + zones = [ + "UTC", + "America/Los_Angeles", + "America/Denver", + "America/Chicago", + "America/New_York", + "Europe/London", + "Europe/Berlin", + "Europe/Paris", + "Asia/Tokyo", + "Asia/Singapore", + "Australia/Sydney", + ]; + } + select.innerHTML = zones.map((zone) => ``).join(""); + select.dataset.populated = "true"; } function hydrateSettingsForm() { dom.settingsForm.scheduleName.value = appConfig.scheduleName || ""; dom.settingsForm.subtitle.value = appConfig.subtitle || ""; dom.settingsForm.minuteGranularity.value = appConfig.minuteGranularity || 5; - dom.settingsForm.lookaheadSeconds.value = - appConfig.lookaheadSeconds || 14 * 24 * 60 * 60; + dom.settingsForm.finishEarlyBufferMinutes.value = + appConfig.finishEarlyBufferMinutes || 15; + dom.settingsForm.includeActiveOccurrences.checked = + appConfig.includeActiveOccurrences !== false; + const lookaheadMinutes = Math.max( + 1, + Math.round((appConfig.lookaheadSeconds || 14 * 24 * 60 * 60) / 60) + ); + dom.settingsForm.lookaheadMinutes.value = lookaheadMinutes; dom.settingsForm.userTimeZone.value = appConfig.userTimeZone || ""; } @@ -102,6 +213,132 @@ function markUnsavedChanges() { } } +const BLOB_TYPES = { + TASK: "task", + EVENT: "event", +}; + +function normalizeBlobType(value) { + return value === BLOB_TYPES.EVENT ? BLOB_TYPES.EVENT : BLOB_TYPES.TASK; +} + +function getRangeInputs(container) { + if (!container) return null; + const direct = { + defaultStart: container.querySelector('[name="defaultStart"]'), + defaultEnd: container.querySelector('[name="defaultEnd"]'), + schedStart: container.querySelector('[name="schedulableStart"]'), + schedEnd: container.querySelector('[name="schedulableEnd"]'), + }; + if (direct.defaultStart && direct.defaultEnd && direct.schedStart && direct.schedEnd) { + return direct; + } + const weekly = { + defaultStart: container.querySelector('[name="slotDefaultStart"]'), + defaultEnd: container.querySelector('[name="slotDefaultEnd"]'), + schedStart: container.querySelector('[name="slotSchedStart"]'), + schedEnd: container.querySelector('[name="slotSchedEnd"]'), + }; + if (weekly.defaultStart && weekly.defaultEnd && weekly.schedStart && weekly.schedEnd) { + return weekly; + } + const multiple = { + defaultStart: container.querySelector('[name="multiDefaultStart"]'), + defaultEnd: container.querySelector('[name="multiDefaultEnd"]'), + schedStart: container.querySelector('[name="multiSchedStart"]'), + schedEnd: container.querySelector('[name="multiSchedEnd"]'), + }; + if (multiple.defaultStart && multiple.defaultEnd && multiple.schedStart && multiple.schedEnd) { + return multiple; + } + return null; +} + +function syncDefaultToSched(container) { + const inputs = getRangeInputs(container); + if (!inputs) return; + if (!inputs.schedStart.value || !inputs.schedEnd.value) return; + inputs.defaultStart.value = inputs.schedStart.value; + inputs.defaultEnd.value = inputs.schedEnd.value; + inputs.defaultStart.dispatchEvent(new Event("change", { bubbles: true })); + inputs.defaultEnd.dispatchEvent(new Event("change", { bubbles: true })); +} + +function setBlobTypeOnContainer(container, nextType) { + if (!container) return; + const type = normalizeBlobType(nextType); + container.dataset.blobType = type; + container.classList.toggle("is-event", type === BLOB_TYPES.EVENT); + const rangeInputs = getRangeInputs(container); + if (rangeInputs?.defaultStart && rangeInputs?.defaultEnd) { + const shouldRequireDefault = type === BLOB_TYPES.TASK; + if (!rangeInputs.defaultStart.disabled) { + rangeInputs.defaultStart.required = shouldRequireDefault; + } + if (!rangeInputs.defaultEnd.disabled) { + rangeInputs.defaultEnd.required = shouldRequireDefault; + } + } + if (container === nonWeeklyField) { + state.currentBlobType = type; + if (state.selectionMode) { + if (type === BLOB_TYPES.EVENT) { + state.selectionStep = "schedulable"; + dom.formStatus.textContent = "Click start/end for schedulable range."; + } else if (!state.pendingDefaultRange) { + state.selectionStep = "default"; + dom.formStatus.textContent = "Click start/end for default range."; + } + } + } + const hiddenInput = container.querySelector("[data-blob-type-input]"); + if (hiddenInput) { + hiddenInput.value = type; + } + container.querySelectorAll("[data-blob-type]").forEach((button) => { + const isActive = button.dataset.blobType === type; + button.classList.toggle("active", isActive); + button.setAttribute("aria-pressed", isActive.toString()); + }); + if (type === BLOB_TYPES.EVENT) { + syncDefaultToSched(container); + } +} + +function isEventFromRanges(defaultStart, defaultEnd, schedStart, schedEnd) { + return Boolean(defaultStart && defaultEnd && schedStart && schedEnd) && + defaultStart === schedStart && + defaultEnd === schedEnd; +} + +function bindBlobTypeSync(container) { + const inputs = getRangeInputs(container); + if (!inputs) return; + const syncIfEvent = () => { + if (container.dataset.blobType === BLOB_TYPES.EVENT) { + syncDefaultToSched(container); + } + }; + inputs.schedStart.addEventListener("change", syncIfEvent); + inputs.schedEnd.addEventListener("change", syncIfEvent); +} + +function bindBlobTypeToggle(container, onChange) { + if (!container) return; + container.querySelectorAll("[data-blob-type]").forEach((button) => { + button.addEventListener("click", () => { + setBlobTypeOnContainer(container, button.dataset.blobType); + if (onChange) { + onChange(); + } + }); + }); + bindBlobTypeSync(container); +} + +const DEFAULT_MAX_SPLITS = 1; +const DEFAULT_MIN_SPLIT_MINUTES = 15; + function getPolicyFlagsFromPolicy(policy = {}) { const rawMask = Number(policy.scheduling_policies); const mask = Number.isFinite(rawMask) ? rawMask : 0; @@ -113,28 +350,71 @@ function getPolicyFlagsFromPolicy(policy = {}) { : Boolean(mask & 2); const invisible = typeof policy.is_invisible === "boolean" ? policy.is_invisible : Boolean(mask & 4); - return { splittable, overlappable, invisible }; + const rawMaxSplits = policy.max_splits; + const maxSplits = rawMaxSplits == null + ? DEFAULT_MAX_SPLITS + : Math.max(0, Math.round(Number(rawMaxSplits))); + const rawMinSplit = policy.min_split_duration_seconds ?? policy.min_split_duration; + const minSplitSeconds = rawMinSplit == null ? null : Number(rawMinSplit); + const minSplitDurationMinutes = Number.isFinite(minSplitSeconds) + ? Math.max(0, Math.round(minSplitSeconds / 60)) + : DEFAULT_MIN_SPLIT_MINUTES; + const roundToGranularity = Boolean(policy.round_to_granularity); + return { splittable, overlappable, invisible, maxSplits, minSplitDurationMinutes, roundToGranularity }; } function getPolicyPayloadFromForm() { + const maxSplitsRaw = dom.blobForm.policyMaxSplits?.value; + const minSplitMinutesRaw = dom.blobForm.policyMinSplitDuration?.value; + const maxSplitsValue = Number(maxSplitsRaw); + const minSplitMinutesValue = Number(minSplitMinutesRaw); + const maxSplits = maxSplitsRaw === "" || !Number.isFinite(maxSplitsValue) + ? DEFAULT_MAX_SPLITS + : Math.max(0, Math.round(maxSplitsValue)); + const minSplitMinutes = minSplitMinutesRaw === "" || !Number.isFinite(minSplitMinutesValue) + ? DEFAULT_MIN_SPLIT_MINUTES + : Math.max(0, Math.round(minSplitMinutesValue)); + const roundToGranularity = Boolean(dom.blobForm.policyRoundToGranularity?.checked); return getPolicyPayloadFromFlags( Boolean(dom.blobForm.policySplittable?.checked), Boolean(dom.blobForm.policyOverlappable?.checked), - Boolean(dom.blobForm.policyInvisible?.checked) + Boolean(dom.blobForm.policyInvisible?.checked), + maxSplits, + minSplitMinutes * 60, + roundToGranularity ); } -function getPolicyPayloadFromFlags(splittable, overlappable, invisible) { +function getPolicyPayloadFromFlags( + splittable, + overlappable, + invisible, + maxSplits = DEFAULT_MAX_SPLITS, + minSplitDurationSeconds = DEFAULT_MIN_SPLIT_MINUTES * 60, + roundToGranularity = false +) { const schedulingPolicies = (splittable ? 1 : 0) | (overlappable ? 2 : 0) | (invisible ? 4 : 0); return { is_splittable: splittable, is_overlappable: overlappable, is_invisible: invisible, + max_splits: maxSplits, + min_split_duration_seconds: minSplitDurationSeconds, + round_to_granularity: roundToGranularity, scheduling_policies: schedulingPolicies, }; } +function setPolicyAdvancedVisibility(container, isSplittable) { + const scope = container?.closest?.(".weekly-slot") + || container?.closest?.("form") + || container; + const advancedRow = scope?.querySelector?.(".slot-policy-advanced"); + if (!advancedRow) return; + advancedRow.classList.toggle("is-hidden", !isSplittable); +} + function setDependencies(ids) { dependencyIds = Array.from(new Set((ids || []).filter(Boolean))); renderDependencyList(); @@ -153,6 +433,18 @@ function tagKey(value) { return normalizeTagName(value).toLowerCase(); } +function parseTagInput(value) { + if (!value) return []; + return value + .split(",") + .map((item) => normalizeTagName(item)) + .filter(Boolean); +} + +function formatTagInput(tags) { + return (tags || []).map((item) => normalizeTagName(item)).filter(Boolean).join(", "); +} + function setTags(tags) { const next = []; const seen = new Set(); @@ -172,6 +464,83 @@ function getTags() { return tagNames.slice(); } +function setSlotTagList(slot, tags) { + const next = []; + const seen = new Set(); + (tags || []).forEach((tag) => { + const name = normalizeTagName(tag); + if (!name) return; + const key = tagKey(name); + if (seen.has(key)) return; + seen.add(key); + next.push(name); + }); + slotTagStore.set(slot, next); + renderSlotTagList(slot); +} + +function getSlotTagList(slot) { + return slotTagStore.get(slot) || []; +} + +function setSlotTags(slot, tags) { + setSlotTagList(slot, tags); +} + +function getSlotTags(slot) { + return getSlotTagList(slot); +} + +function setSlotDependencies(slot, ids) { + const next = []; + const seen = new Set(); + (ids || []).forEach((id) => { + const value = typeof id === "string" ? id.trim() : ""; + if (!value) return; + if (seen.has(value)) return; + seen.add(value); + next.push(value); + }); + slotDependencyStore.set(slot, next); + renderSlotDependencyList(slot); +} + +function getSlotDependencies(slot) { + return slotDependencyStore.get(slot) || []; +} + +function getRecurrenceColor() { + const selected = document.querySelector('input[name="recurrenceColor"]:checked'); + const value = selected?.value || "default"; + return value === "default" ? null : value; +} + +function setRecurrenceColor(value) { + const target = value || "default"; + document.querySelectorAll('input[name="recurrenceColor"]').forEach((input) => { + input.checked = input.value === target; + }); +} + +function getRecurrenceEndValue() { + if (!dom.recurrenceEnd) return null; + const value = dom.recurrenceEnd.value; + if (!value) return null; + const iso = toProjectIsoFromLocalInput( + value, + appConfig.userTimeZone, + appConfig.projectTimeZone + ); + return iso || null; +} + +function setRecurrenceEndValue(value) { + if (!dom.recurrenceEnd) return; + dom.recurrenceEnd.value = value + ? toLocalInputValueInTimeZone(value, appConfig.userTimeZone) + : ""; +} + function findBlobById(id) { return state.blobs.find((item) => item.id === id) || null; } @@ -222,6 +591,31 @@ function getTagSuggestions(query) { return matches.slice(0, 6); } +function getSlotTagSuggestions(query, selectedTags) { + const normalized = query.trim().toLowerCase(); + if (!normalized) return []; + const selected = new Set((selectedTags || []).map((tag) => tagKey(tag))); + const matches = getAvailableTags().filter((tag) => { + if (selected.has(tagKey(tag))) return false; + return tag.toLowerCase().includes(normalized); + }); + matches.sort((a, b) => a.localeCompare(b)); + return matches.slice(0, 6); +} + +function getSlotDependencySuggestions(query, selectedIds) { + const normalized = query.trim().toLowerCase(); + if (!normalized) return []; + const selected = new Set((selectedIds || []).map((id) => id.toLowerCase())); + const matches = state.blobs.filter((item) => { + if (selected.has(item.id.toLowerCase())) return false; + const name = item.name?.toLowerCase() || ""; + return name.includes(normalized) || item.id.toLowerCase().includes(normalized); + }); + matches.sort((a, b) => (a.name || "").localeCompare(b.name || "")); + return matches.slice(0, 6); +} + function renderDependencySuggestions() { if (!dom.dependencySuggestions) return; const query = dom.dependencyInput?.value || ""; @@ -255,6 +649,43 @@ function renderTagSuggestions() { }); } +function renderSlotTagSuggestions(slot) { + const suggestions = slot.querySelector(".slot-tag-suggestions"); + const input = slot.querySelector('[name="slotTagInput"]'); + if (!suggestions || !input) return; + const query = input.value || ""; + const matches = getSlotTagSuggestions(query, getSlotTagList(slot)); + suggestions.innerHTML = ""; + if (!query.trim() || matches.length === 0) return; + matches.forEach((match) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "tag-suggestion"; + button.dataset.tagName = match; + button.textContent = match; + suggestions.appendChild(button); + }); +} + +function renderSlotDependencySuggestions(slot) { + const suggestions = slot.querySelector(".slot-dependency-suggestions"); + const input = slot.querySelector('[name="slotDependencyInput"]'); + if (!suggestions || !input) return; + const query = input.value || ""; + const matches = getSlotDependencySuggestions(query, getSlotDependencies(slot)); + suggestions.innerHTML = ""; + if (!query.trim() || matches.length === 0) return; + matches.forEach((match) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "dependency-suggestion"; + button.dataset.dependencyId = match.id; + button.textContent = match.name || "Untitled"; + button.title = match.id; + suggestions.appendChild(button); + }); +} + function renderDependencyList() { if (!dom.dependencyList) return; dom.dependencyList.innerHTML = ""; @@ -349,6 +780,87 @@ function renderTagList() { }); } +function renderSlotTagList(slot) { + const list = slot.querySelector(".slot-tag-list"); + if (!list) return; + list.innerHTML = ""; + getSlotTagList(slot).forEach((name) => { + const pill = document.createElement("div"); + pill.className = "tag-pill"; + pill.dataset.tagName = name; + + const label = document.createElement("span"); + label.className = "tag-name"; + label.textContent = name; + pill.appendChild(label); + + const tooltip = document.createElement("div"); + tooltip.className = "tag-tooltip"; + const title = document.createElement("div"); + title.className = "tag-tooltip-title"; + title.textContent = name; + tooltip.appendChild(title); + + const removeBtn = document.createElement("button"); + removeBtn.type = "button"; + removeBtn.className = "ghost small tag-remove"; + removeBtn.textContent = "Remove tag"; + removeBtn.dataset.removeSlotTag = name; + tooltip.appendChild(removeBtn); + + pill.appendChild(tooltip); + list.appendChild(pill); + }); +} + +function renderSlotDependencyList(slot) { + const list = slot.querySelector(".slot-dependency-list"); + if (!list) return; + list.innerHTML = ""; + getSlotDependencies(slot).forEach((id) => { + const blob = findBlobById(id); + const name = blob?.name || "Unknown blob"; + const pill = document.createElement("div"); + pill.className = "dependency-pill"; + pill.dataset.dependencyId = id; + + const label = document.createElement("span"); + label.className = "dependency-name"; + label.textContent = name; + pill.appendChild(label); + + const tooltip = document.createElement("div"); + tooltip.className = "dependency-tooltip"; + + const title = document.createElement("div"); + title.className = "dependency-tooltip-title"; + title.textContent = name; + tooltip.appendChild(title); + + const idRow = document.createElement("div"); + idRow.className = "dependency-tooltip-row"; + const idLabel = document.createElement("span"); + idLabel.className = "dependency-tooltip-label"; + idLabel.textContent = "Blob id"; + const idValue = document.createElement("span"); + idValue.className = "dependency-tooltip-value"; + idValue.textContent = id; + idRow.appendChild(idLabel); + idRow.appendChild(idValue); + tooltip.appendChild(idRow); + + const removeBtn = document.createElement("button"); + removeBtn.type = "button"; + removeBtn.className = "ghost small dependency-remove"; + removeBtn.textContent = "Remove dependency"; + removeBtn.dataset.removeSlotDependency = id; + tooltip.appendChild(removeBtn); + + pill.appendChild(tooltip); + list.appendChild(pill); + }); +} + function addDependencyFromInput() { if (!dom.dependencyInput) return; const raw = dom.dependencyInput.value.trim(); @@ -388,6 +900,52 @@ function addTagFromInput() { dom.tagSuggestions.innerHTML = ""; } +function addSlotTagFromInput(slot) { + const input = slot.querySelector('[name="slotTagInput"]'); + const suggestions = slot.querySelector(".slot-tag-suggestions"); + if (!input) return; + const raw = input.value.trim(); + if (!raw) return; + const matches = getSlotTagSuggestions(raw, getSlotTagList(slot)); + const candidate = matches[0] || raw; + const key = tagKey(candidate); + const seen = new Set(getSlotTagList(slot).map((tag) => tagKey(tag))); + if (!seen.has(key)) { + setSlotTagList(slot, [...getSlotTagList(slot), candidate]); + } + dom.formStatus.textContent = ""; + input.value = ""; + if (suggestions) { + suggestions.innerHTML = ""; + } +} + +function addSlotDependencyFromInput(slot) { + const input = slot.querySelector('[name="slotDependencyInput"]'); + const suggestions = slot.querySelector(".slot-dependency-suggestions"); + if (!input) return; + const raw = input.value.trim(); + if (!raw) return; + const matches = getSlotDependencySuggestions(raw, getSlotDependencies(slot)); + const candidate = + matches[0] || + findBlobById(raw) || + findBlobByName(raw); + if (!candidate) { + dom.formStatus.textContent = "No matching blob found."; + return; + } + const existing = getSlotDependencies(slot); + if (!existing.includes(candidate.id)) { + setSlotDependencies(slot, [...existing, candidate.id]); + } + dom.formStatus.textContent = ""; + input.value = ""; + if (suggestions) { + suggestions.innerHTML = ""; + } +} + function applyPolicyToForm(policy) { const flags = getPolicyFlagsFromPolicy(policy || {}); if (dom.blobForm.policySplittable) { @@ -399,6 +957,16 @@ function applyPolicyToForm(policy) { if (dom.blobForm.policyInvisible) { dom.blobForm.policyInvisible.checked = flags.invisible; } + if (dom.blobForm.policyMaxSplits) { + dom.blobForm.policyMaxSplits.value = String(flags.maxSplits ?? 0); + } + if (dom.blobForm.policyMinSplitDuration) { + dom.blobForm.policyMinSplitDuration.value = String(flags.minSplitDurationMinutes ?? 0); + } + if (dom.blobForm.policyRoundToGranularity) { + dom.blobForm.policyRoundToGranularity.checked = flags.roundToGranularity; + } + setPolicyAdvancedVisibility(dom.blobForm.policySplittable || dom.blobForm, flags.splittable); } function applyPolicyToSlot(slot, policy) { @@ -406,9 +974,20 @@ function applyPolicyToSlot(slot, policy) { const splittableEl = slot.querySelector('[name="slotPolicySplittable"]'); const overlappableEl = slot.querySelector('[name="slotPolicyOverlappable"]'); const invisibleEl = slot.querySelector('[name="slotPolicyInvisible"]'); + const maxSplitsEl = slot.querySelector('[name="slotPolicyMaxSplits"]'); + const minSplitDurationEl = slot.querySelector('[name="slotPolicyMinSplitDuration"]'); + const roundToGranularityEl = slot.querySelector('[name="slotPolicyRoundToGranularity"]'); if (splittableEl) splittableEl.checked = flags.splittable; if (overlappableEl) overlappableEl.checked = flags.overlappable; if (invisibleEl) invisibleEl.checked = flags.invisible; + if (maxSplitsEl) maxSplitsEl.value = String(flags.maxSplits ?? 0); + if (minSplitDurationEl) { + minSplitDurationEl.value = String(flags.minSplitDurationMinutes ?? 0); + } + if (roundToGranularityEl) { + roundToGranularityEl.checked = flags.roundToGranularity; + } + setPolicyAdvancedVisibility(slot, flags.splittable); } function syncSlotPoliciesFromForm() { @@ -419,146 +998,692 @@ function syncSlotPoliciesFromForm() { }); } +function syncSlotTagsFromForm() { + if (!dom.weeklySlots) return; + const sharedTags = getTags(); + dom.weeklySlots.querySelectorAll(".weekly-slot").forEach((slot) => { + setSlotTags(slot, sharedTags); + }); +} + +function collectSlotTagsUnion() { + if (!dom.weeklySlots) return []; + const merged = []; + const seen = new Set(); + dom.weeklySlots.querySelectorAll(".weekly-slot").forEach((slot) => { + getSlotTags(slot).forEach((tag) => { + const name = normalizeTagName(tag); + if (!name) return; + const key = tagKey(name); + if (seen.has(key)) return; + seen.add(key); + merged.push(name); + }); + }); + return merged; +} + function updateRecurrenceUI() { const type = dom.recurrenceType?.value || "single"; + const isMultiple = type === "multiple"; + storeWeeklyFieldPlacement(weeklyFieldPlacement.dependency); + storeWeeklyFieldPlacement(weeklyFieldPlacement.tag); + if (type === "weekly" && weeklyRecurrenceFields) { + moveWeeklyField(weeklyFieldPlacement.tag, weeklyRecurrenceFields); + restoreWeeklyField(weeklyFieldPlacement.dependency); + } else { + restoreWeeklyField(weeklyFieldPlacement.dependency); + restoreWeeklyField(weeklyFieldPlacement.tag); + } recurrenceFieldGroups.forEach((group) => { const matches = group.dataset.recurrence === type; group.classList.toggle("active", matches); }); dom.formPanel.classList.toggle("weekly-mode", type === "weekly"); + dom.formPanel.classList.toggle("date-mode", type === "date"); + dom.formPanel.classList.toggle("multiple-mode", isMultiple); + dom.formPanel.classList.toggle("single-mode", type === "single"); const weeklyWrapper = dom.weeklySlots?.closest(".weekly-slots"); if (weeklyWrapper) { weeklyWrapper.classList.toggle("per-slot", Boolean(dom.weeklyPerSlot?.checked)); } + const isWeeklyPerSlot = Boolean(dom.weeklyPerSlot?.checked); + document.querySelectorAll(".non-weekly-field").forEach((field) => { + field.classList.toggle("hidden", isMultiple); + }); + document.querySelectorAll(".dependency-field:not(.slot-dependency-field)").forEach((field) => { + field.classList.toggle("hidden", isMultiple || type === "weekly"); + }); + document.querySelectorAll(".tag-field").forEach((field) => { + field.classList.toggle("hidden", isMultiple || (type === "weekly" && isWeeklyPerSlot)); + }); + document.querySelectorAll(".color-field").forEach((field) => { + field.classList.toggle("hidden", isMultiple); + }); + document.querySelectorAll(".recurrence-extras").forEach((field) => { + field.classList.toggle("hidden", isMultiple); + }); document.querySelectorAll(".non-weekly-field input").forEach((field) => { - const shouldDisable = type === "weekly"; const isCheckbox = field.type === "checkbox"; - const isOptional = field.name === "blobDescription"; - field.disabled = shouldDisable; - field.required = !shouldDisable && !isCheckbox && !isOptional; + const isOptional = field.type === "hidden" || field.name === "blobDescription"; + const isMetaField = field.name === "blobName" || field.name === "blobDescription"; + const isPolicyField = field.name?.startsWith?.("policy"); + const isTimeRangeField = [ + "defaultStart", + "defaultEnd", + "schedulableStart", + "schedulableEnd", + ].includes(field.name); + if (type === "weekly" || isMultiple) { + field.disabled = true; + field.required = false; + } else if (type === "single" && isMetaField) { + field.disabled = true; + field.required = false; + } else if (type === "date") { + if (isTimeRangeField || isMetaField || isPolicyField) { + field.disabled = true; + field.required = false; + } else { + field.disabled = false; + field.required = !isCheckbox && !isOptional; + } + } else { + field.disabled = false; + field.required = !isCheckbox && !isOptional; + } }); + document.querySelectorAll(".multiple-slots input").forEach((field) => { + if (isMultiple) { + field.disabled = false; + if (field.name === "multiName") { + field.required = true; + } + } else { + field.disabled = true; + field.required = false; + } + }); + const annualDateField = dom.blobForm.annualDate; + if (annualDateField) { + annualDateField.disabled = type !== "date"; + annualDateField.required = type === "date"; + } + if (dom.recurrenceEnd) { + dom.recurrenceEnd.disabled = type === "single" || isMultiple; + if (type === "single" || isMultiple) { + dom.recurrenceEnd.value = ""; + } + } - const defaultStart = dom.blobForm.defaultStart.value; - const startDate = defaultStart - ? new Date( - toProjectIsoFromLocalInput( - defaultStart, - appConfig.userTimeZone, - appConfig.projectTimeZone - ) - ) - : state.anchorDate; - if (Number.isNaN(startDate.getTime())) { - dom.recurrenceSummary.textContent = "Set a default start time to preview the cadence."; - return; + if (dom.blobForm?.policySplittable) { + setPolicyAdvancedVisibility(dom.blobForm.policySplittable, dom.blobForm.policySplittable.checked); + } + + if (type === "multiple") { + const slotCount = getMultipleSlots().length; + dom.recurrenceSummary.textContent = `Grouping ${slotCount} occurrence(s).`; + } else { + const defaultStart = dom.blobForm.defaultStart.value; + const annualDate = dom.blobForm.annualDate?.value || ""; + const startDate = annualDate + ? new Date(`${annualDate}T00:00:00`) + : defaultStart + ? new Date( + toProjectIsoFromLocalInput( + defaultStart, + appConfig.userTimeZone, + appConfig.projectTimeZone + ) + ) + : state.anchorDate; + if (Number.isNaN(startDate.getTime())) { + dom.recurrenceSummary.textContent = "Set a default start time to preview the cadence."; + return; + } + if (type === "weekly") { + const interval = Number(dom.blobForm.weeklyInterval.value || 1); + const slotSelections = getWeeklySlotSelections(); + const slotCount = slotSelections.reduce((total, slot) => total + slot.days.length, 0); + dom.recurrenceSummary.textContent = `Repeats every ${interval} week(s) with ${slotCount} occurrence(s).`; + } else if (type === "delta") { + const value = Number(dom.blobForm.deltaValue.value || 1); + const unit = dom.blobForm.deltaUnit.value || "days"; + dom.recurrenceSummary.textContent = `Repeats every ${value} ${unit}.`; + } else if (type === "date") { + if (!annualDate) { + dom.recurrenceSummary.textContent = "Select an annual date."; + return; + } + const dateLabel = startDate.toLocaleDateString(undefined, { + month: "long", + day: "numeric", + }); + dom.recurrenceSummary.textContent = `Repeats annually on ${dateLabel}.`; + } else { + dom.recurrenceSummary.textContent = "One-time event. Switch to create repeats."; + } + } + + const endValue = dom.recurrenceEnd?.value; + if (endValue && type !== "single" && !isMultiple) { + const endDate = new Date(endValue); + if (!Number.isNaN(endDate.getTime())) { + const endLabel = endDate.toLocaleString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + dom.recurrenceSummary.textContent = `${dom.recurrenceSummary.textContent} Ends ${endLabel}.`; + } + } +} + +function timeToMinutes(value) { + if (!value) return null; + const [hours, minutes] = value.split(":").map((part) => Number(part)); + if (Number.isNaN(hours) || Number.isNaN(minutes)) return null; + return hours * 60 + minutes; +} + +function timeValueFromDate(date, fallback, timeZone) { + if (!date || Number.isNaN(date.getTime())) return fallback; + const local = formatDateTimeLocalInTimeZone(date, timeZone); + return local.split("T")[1] || fallback; +} + +function weekdayIndexFromDateString(dateString) { + const [year, month, day] = dateString.split("-").map((part) => Number(part)); + if ([year, month, day].some((item) => Number.isNaN(item))) { + return 0; + } + return new Date(Date.UTC(year, month - 1, day)).getUTCDay(); +} + +function dayOffsetFromSunday(dayIndex) { + return dayIndex; +} + +function clearWeeklySlots() { + if (dom.weeklySlots) { + dom.weeklySlots.innerHTML = ""; + } +} + +function createWeeklySlot(slotData = {}) { + if (!dom.weeklySlots) return; + const lastSlot = dom.weeklySlots.querySelector(".weekly-slot:last-of-type"); + const lastValues = lastSlot + ? { + defaultStart: lastSlot.querySelector('[name="slotDefaultStart"]')?.value, + defaultEnd: lastSlot.querySelector('[name="slotDefaultEnd"]')?.value, + schedStart: lastSlot.querySelector('[name="slotSchedStart"]')?.value, + schedEnd: lastSlot.querySelector('[name="slotSchedEnd"]')?.value, + } + : null; + const slot = document.createElement("div"); + slot.className = "weekly-slot"; + const slotId = typeof crypto !== "undefined" && crypto.randomUUID + ? crypto.randomUUID() + : `slot-${Date.now()}-${Math.random().toString(16).slice(2)}`; + slot.dataset.slotId = slotId; + const dayValues = Array.isArray(slotData.days) + ? slotData.days + : [slotData.day ?? 0]; + const defaultStart = slotData.defaultStart || lastValues?.defaultStart || "09:00"; + const defaultEnd = slotData.defaultEnd || lastValues?.defaultEnd || "10:00"; + const schedStart = slotData.schedStart || lastValues?.schedStart || "08:30"; + const schedEnd = slotData.schedEnd || lastValues?.schedEnd || "10:30"; + const nameValue = slotData.name || ""; + const descriptionValue = slotData.description || ""; + const tagsValue = slotData.tags || []; + const fallbackPolicy = dom.weeklyPerSlot?.checked ? getPolicyPayloadFromForm() : {}; + const policyFlags = getPolicyFlagsFromPolicy(slotData.policy ?? fallbackPolicy); + const slotType = normalizeBlobType( + slotData.blobType || (isEventFromRanges(defaultStart, defaultEnd, schedStart, schedEnd) + ? BLOB_TYPES.EVENT + : BLOB_TYPES.TASK) + ); + slot.innerHTML = ` +
+
+ Days +
+ ${WEEK_DAYS.map( + (day, index) => + `` + ).join("")} +
+
+
+
+
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ Policy options +
+
+ + + +
+
+ + + +
+
+ Tags +
+ + +
+
+
+
+
+ +
+ `; + setBlobTypeOnContainer(slot, slotType); + slot.querySelector('[data-action="remove-slot"]').addEventListener("click", () => { + slot.remove(); + updateRecurrenceUI(); + validateWeeklySlots(); + }); + slotTagStore.set(slot, Array.isArray(tagsValue) ? tagsValue : []); + renderSlotTagList(slot); + slot.querySelectorAll("input").forEach((field) => { + field.addEventListener("change", () => { + if (field.name === "slotPolicySplittable") { + setPolicyAdvancedVisibility(slot, field.checked); + } + updateRecurrenceUI(); + validateWeeklySlots(); + }); + }); + slot.querySelectorAll(".day-pill").forEach((button) => { + button.addEventListener("click", () => { + button.classList.toggle("active"); + button.setAttribute( + "aria-pressed", + button.classList.contains("active") ? "true" : "false" + ); + updateRecurrenceUI(); + validateWeeklySlots(); + }); + }); + const slotTagInput = slot.querySelector('[name="slotTagInput"]'); + const slotTagSuggestions = slot.querySelector(".slot-tag-suggestions"); + const addSlotTagBtn = slot.querySelector('[data-action="add-slot-tag"]'); + if (slotTagInput) { + slotTagInput.addEventListener("input", () => renderSlotTagSuggestions(slot)); + slotTagInput.addEventListener("focus", () => renderSlotTagSuggestions(slot)); + slotTagInput.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + addSlotTagFromInput(slot); + } + }); + } + if (addSlotTagBtn) { + addSlotTagBtn.addEventListener("click", () => addSlotTagFromInput(slot)); + } + if (slotTagSuggestions) { + slotTagSuggestions.addEventListener("click", (event) => { + const target = event.target.closest(".tag-suggestion"); + if (!target) return; + const name = target.dataset.tagName; + if (name) { + const key = tagKey(name); + const seen = new Set(getSlotTagList(slot).map((tag) => tagKey(tag))); + if (!seen.has(key)) { + setSlotTagList(slot, [...getSlotTagList(slot), name]); + } + } + if (slotTagInput) { + slotTagInput.value = ""; + } + slotTagSuggestions.innerHTML = ""; + }); + } + const slotTagList = slot.querySelector(".slot-tag-list"); + if (slotTagList) { + slotTagList.addEventListener("click", (event) => { + const target = event.target.closest("[data-remove-slot-tag]"); + if (!target) return; + const name = target.dataset.removeSlotTag; + setSlotTagList( + slot, + getSlotTagList(slot).filter((tag) => tagKey(tag) !== tagKey(name)) + ); + }); + } + bindBlobTypeToggle(slot, () => { + validateWeeklySlots(); + }); + dom.weeklySlots.appendChild(slot); + updateRecurrenceUI(); + validateWeeklySlots(); +} + +function getSelectedDays(slot) { + return Array.from(slot.querySelectorAll(".day-pill.active")) + .map((pill) => Number(pill.dataset.day)) + .filter((value) => !Number.isNaN(value)); +} + +function getWeeklySlotSelections() { + if (!dom.weeklySlots) return []; + const selections = []; + dom.weeklySlots.querySelectorAll(".weekly-slot").forEach((slot) => { + const splittable = Boolean(slot.querySelector('[name="slotPolicySplittable"]')?.checked); + const overlappable = Boolean(slot.querySelector('[name="slotPolicyOverlappable"]')?.checked); + const invisible = Boolean(slot.querySelector('[name="slotPolicyInvisible"]')?.checked); + const maxSplitsRaw = slot.querySelector('[name="slotPolicyMaxSplits"]')?.value; + const minSplitMinutesRaw = slot.querySelector('[name="slotPolicyMinSplitDuration"]')?.value; + const maxSplitsValue = Number(maxSplitsRaw); + const minSplitMinutesValue = Number(minSplitMinutesRaw); + const maxSplits = maxSplitsRaw === "" || !Number.isFinite(maxSplitsValue) + ? DEFAULT_MAX_SPLITS + : Math.max(0, Math.round(maxSplitsValue)); + const minSplitMinutes = minSplitMinutesRaw === "" || !Number.isFinite(minSplitMinutesValue) + ? DEFAULT_MIN_SPLIT_MINUTES + : Math.max(0, Math.round(minSplitMinutesValue)); + const roundToGranularity = Boolean( + slot.querySelector('[name="slotPolicyRoundToGranularity"]')?.checked + ); + const slotType = normalizeBlobType(slot.querySelector('[name="slotBlobType"]')?.value); + const schedStart = slot.querySelector('[name="slotSchedStart"]').value; + const schedEnd = slot.querySelector('[name="slotSchedEnd"]').value; + const defaultStart = slotType === BLOB_TYPES.EVENT + ? schedStart + : slot.querySelector('[name="slotDefaultStart"]').value; + const defaultEnd = slotType === BLOB_TYPES.EVENT + ? schedEnd + : slot.querySelector('[name="slotDefaultEnd"]').value; + selections.push({ + days: getSelectedDays(slot), + blobType: slotType, + defaultStart, + defaultEnd, + schedStart, + schedEnd, + name: slot.querySelector('[name="slotName"]').value, + description: slot.querySelector('[name="slotDescription"]').value, + tags: getSlotTags(slot), + policy: getPolicyPayloadFromFlags( + splittable, + overlappable, + invisible, + maxSplits, + minSplitMinutes * 60, + roundToGranularity + ), + }); + }); + return selections; +} + +function getWeeklySlots() { + const slots = []; + getWeeklySlotSelections().forEach((slot) => { + slot.days.forEach((day) => { + slots.push({ ...slot, day }); + }); + }); + return slots; +} + +function validateWeeklySlots() { + if (!dom.weeklySlotStatus) return true; + const selections = getWeeklySlotSelections(); + if (selections.length === 0) { + dom.weeklySlotStatus.textContent = "Add at least one weekly occurrence."; + return false; } - if (type === "weekly") { - const interval = Number(dom.blobForm.weeklyInterval.value || 1); - const slotCount = dom.weeklySlots?.querySelectorAll(".weekly-slot").length || 0; - dom.recurrenceSummary.textContent = `Repeats every ${interval} week(s) with ${slotCount} slot(s).`; - } else if (type === "delta") { - const value = Number(dom.blobForm.deltaValue.value || 1); - const unit = dom.blobForm.deltaUnit.value || "days"; - dom.recurrenceSummary.textContent = `Repeats every ${value} ${unit}.`; - } else if (type === "date") { - const dateLabel = startDate.toLocaleDateString(undefined, { - month: "long", - day: "numeric", + for (const slot of selections) { + if (slot.days.length === 0) { + dom.weeklySlotStatus.textContent = "Weekly occurrences need a day of week."; + return false; + } + } + const slots = getWeeklySlots(); + const ranges = []; + for (const slot of slots) { + const defaultStart = timeToMinutes(slot.defaultStart); + const defaultEnd = timeToMinutes(slot.defaultEnd); + const schedStart = timeToMinutes(slot.schedStart); + const schedEnd = timeToMinutes(slot.schedEnd); + if ( + defaultStart === null || + defaultEnd === null || + schedStart === null || + schedEnd === null + ) { + dom.weeklySlotStatus.textContent = "Weekly occurrences need valid times."; + return false; + } + if (defaultEnd <= defaultStart || schedEnd <= schedStart) { + dom.weeklySlotStatus.textContent = "Weekly occurrences must end after they start."; + return false; + } + if (schedStart > defaultStart || schedEnd < defaultEnd) { + dom.weeklySlotStatus.textContent = + "Schedulable range must contain default range for each occurrence."; + return false; + } + const offset = dayOffsetFromSunday(slot.day); + ranges.push({ + start: offset * 1440 + schedStart, + end: offset * 1440 + schedEnd, }); - dom.recurrenceSummary.textContent = `Repeats annually on ${dateLabel}.`; - } else { - dom.recurrenceSummary.textContent = "One-time event. Switch to create repeats."; } -} - -function timeToMinutes(value) { - if (!value) return null; - const [hours, minutes] = value.split(":").map((part) => Number(part)); - if (Number.isNaN(hours) || Number.isNaN(minutes)) return null; - return hours * 60 + minutes; -} - -function timeValueFromDate(date, fallback, timeZone) { - if (!date || Number.isNaN(date.getTime())) return fallback; - const local = formatDateTimeLocalInTimeZone(date, timeZone); - return local.split("T")[1] || fallback; -} - -function weekdayIndexFromDateString(dateString) { - const [year, month, day] = dateString.split("-").map((part) => Number(part)); - if ([year, month, day].some((item) => Number.isNaN(item))) { - return 1; + const sorted = ranges.sort((a, b) => a.start - b.start); + for (let i = 0; i < sorted.length - 1; i += 1) { + if (sorted[i].end > sorted[i + 1].start) { + dom.weeklySlotStatus.textContent = "Weekly occurrences cannot overlap."; + return false; + } } - return new Date(Date.UTC(year, month - 1, day)).getUTCDay(); -} - -function dayOffsetFromMonday(dayIndex) { - return (dayIndex + 6) % 7; + dom.weeklySlotStatus.textContent = ""; + return true; } -function clearWeeklySlots() { - if (dom.weeklySlots) { - dom.weeklySlots.innerHTML = ""; +function clearMultipleSlots() { + if (dom.multipleSlots) { + dom.multipleSlots.innerHTML = ""; } } -function createWeeklySlot(slotData = {}) { - if (!dom.weeklySlots) return; +function createMultipleSlot(slotData = {}) { + if (!dom.multipleSlots) return; const slot = document.createElement("div"); - slot.className = "weekly-slot"; + slot.className = "weekly-slot multiple-slot"; const slotId = typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() - : `slot-${Date.now()}-${Math.random().toString(16).slice(2)}`; + : `multi-slot-${Date.now()}-${Math.random().toString(16).slice(2)}`; slot.dataset.slotId = slotId; - const dayValue = slotData.day ?? 1; - const defaultStart = slotData.defaultStart || "09:00"; - const defaultEnd = slotData.defaultEnd || "10:00"; - const schedStart = slotData.schedStart || "08:30"; - const schedEnd = slotData.schedEnd || "10:30"; + + const defaultStart = slotData.defaultStart || ""; + const defaultEnd = slotData.defaultEnd || ""; + const schedStart = slotData.schedStart || ""; + const schedEnd = slotData.schedEnd || ""; const nameValue = slotData.name || ""; const descriptionValue = slotData.description || ""; - const fallbackPolicy = dom.weeklyPerSlot?.checked ? getPolicyPayloadFromForm() : {}; - const policyFlags = getPolicyFlagsFromPolicy(slotData.policy ?? fallbackPolicy); + const tagsValue = slotData.tags || []; + const policyFlags = getPolicyFlagsFromPolicy(slotData.policy || {}); + const slotType = normalizeBlobType( + slotData.blobType || (isEventFromRanges(defaultStart, defaultEnd, schedStart, schedEnd) + ? BLOB_TYPES.EVENT + : BLOB_TYPES.TASK) + ); + slot.innerHTML = ` -
- +
+
+ + +
+ +
+
+
+
+
+ Policy options +
-
-
- +
+ + + +
+
+ Dependencies +
+ + +
+
+
+
+
+ Tags +
+ + +
+
+
+
+
+
`; - slot.querySelector('[data-action="remove-slot"]').addEventListener("click", () => { - slot.remove(); - updateRecurrenceUI(); - validateWeeklySlots(); + + dom.multipleSlots.appendChild(slot); + + const defaultStartInput = slot.querySelector('[name="multiDefaultStart"]'); + const defaultEndInput = slot.querySelector('[name="multiDefaultEnd"]'); + const schedStartInput = slot.querySelector('[name="multiSchedStart"]'); + const schedEndInput = slot.querySelector('[name="multiSchedEnd"]'); + if (defaultStartInput) defaultStartInput.value = defaultStart; + if (defaultEndInput) defaultEndInput.value = defaultEnd; + if (schedStartInput) schedStartInput.value = schedStart; + if (schedEndInput) schedEndInput.value = schedEnd; + setBlobTypeOnContainer(slot, slotType); + + setSlotTagList(slot, Array.isArray(tagsValue) ? tagsValue : []); + setSlotDependencies(slot, Array.isArray(slotData.dependencies) ? slotData.dependencies : []); + + const removeBtn = slot.querySelector('[data-action="remove-multiple-slot"]'); + if (removeBtn) { + removeBtn.addEventListener("click", () => { + slot.remove(); + validateMultipleSlots(); + updateRecurrenceUI(); + }); + } + + bindDateTimePickers(); + syncDateTimeDisplays(); + const tagInput = slot.querySelector('[name="slotTagInput"]'); + const tagSuggestions = slot.querySelector(".slot-tag-suggestions"); + const addTagBtn = slot.querySelector('[data-action="add-slot-tag"]'); + if (tagInput) { + tagInput.addEventListener("input", () => renderSlotTagSuggestions(slot)); + tagInput.addEventListener("focus", () => renderSlotTagSuggestions(slot)); + tagInput.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === ",") { + event.preventDefault(); + addSlotTagFromInput(slot); + } + }); + } + if (addTagBtn) { + addTagBtn.addEventListener("click", () => addSlotTagFromInput(slot)); + } + if (tagSuggestions) { + tagSuggestions.addEventListener("click", (event) => { + const target = event.target.closest("[data-tag-name]"); + if (!target) return; + const candidate = target.dataset.tagName; + if (!candidate) return; + const currentTags = getSlotTagList(slot); + if (!currentTags.some((tag) => tagKey(tag) === tagKey(candidate))) { + setSlotTagList(slot, [...currentTags, candidate]); + } + if (tagInput) { + tagInput.value = ""; + } + tagSuggestions.innerHTML = ""; + }); + } + const tagList = slot.querySelector(".slot-tag-list"); + if (tagList) { + tagList.addEventListener("click", (event) => { + const target = event.target.closest("[data-remove-slot-tag]"); + if (!target) return; + const name = target.dataset.removeSlotTag; + setSlotTagList( + slot, + getSlotTagList(slot).filter((tag) => tagKey(tag) !== tagKey(name)) + ); + }); + } + const dependencyInput = slot.querySelector('[name="slotDependencyInput"]'); + const dependencySuggestions = slot.querySelector(".slot-dependency-suggestions"); + const addDependencyBtn = slot.querySelector('[data-action="add-slot-dependency"]'); + if (dependencyInput) { + dependencyInput.addEventListener("input", () => renderSlotDependencySuggestions(slot)); + dependencyInput.addEventListener("focus", () => renderSlotDependencySuggestions(slot)); + dependencyInput.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === ",") { + event.preventDefault(); + addSlotDependencyFromInput(slot); + } + }); + } + if (addDependencyBtn) { + addDependencyBtn.addEventListener("click", () => addSlotDependencyFromInput(slot)); + } + if (dependencySuggestions) { + dependencySuggestions.addEventListener("click", (event) => { + const target = event.target.closest("[data-dependency-id]"); + if (!target) return; + const candidate = target.dataset.dependencyId; + if (!candidate) return; + const current = getSlotDependencies(slot); + if (!current.includes(candidate)) { + setSlotDependencies(slot, [...current, candidate]); + } + if (dependencyInput) { + dependencyInput.value = ""; + } + dependencySuggestions.innerHTML = ""; + }); + } + const dependencyList = slot.querySelector(".slot-dependency-list"); + if (dependencyList) { + dependencyList.addEventListener("click", (event) => { + const target = event.target.closest("[data-remove-slot-dependency]"); + if (!target) return; + const id = target.dataset.removeSlotDependency; + setSlotDependencies( + slot, + getSlotDependencies(slot).filter((depId) => depId !== id) + ); + }); + } + bindBlobTypeToggle(slot, () => { + validateMultipleSlots(); }); - slot.querySelectorAll("input, select").forEach((field) => { + ["slotPolicySplittable", "slotPolicyOverlappable", "slotPolicyInvisible"].forEach((name) => { + const field = slot.querySelector(`[name="${name}"]`); + if (!field) return; field.addEventListener("change", () => { - updateRecurrenceUI(); - validateWeeklySlots(); + if (name === "slotPolicySplittable") { + setPolicyAdvancedVisibility(field, field.checked); + } }); }); - dom.weeklySlots.appendChild(slot); + + validateMultipleSlots(); updateRecurrenceUI(); - validateWeeklySlots(); } -function getWeeklySlots() { - if (!dom.weeklySlots) return []; +function getMultipleSlots() { + if (!dom.multipleSlots) return []; const slots = []; - dom.weeklySlots.querySelectorAll(".weekly-slot").forEach((slot) => { - const day = Number(slot.querySelector('[name="slotDay"]').value); - const splittable = Boolean(slot.querySelector('[name="slotPolicySplittable"]')?.checked); - const overlappable = Boolean(slot.querySelector('[name="slotPolicyOverlappable"]')?.checked); - const invisible = Boolean(slot.querySelector('[name="slotPolicyInvisible"]')?.checked); + dom.multipleSlots.querySelectorAll(".multiple-slot").forEach((slot) => { + const slotType = normalizeBlobType(slot.querySelector('[name="multiBlobType"]')?.value); + const schedStart = slot.querySelector('[name="multiSchedStart"]')?.value || ""; + const schedEnd = slot.querySelector('[name="multiSchedEnd"]')?.value || ""; slots.push({ - day, - defaultStart: slot.querySelector('[name="slotDefaultStart"]').value, - defaultEnd: slot.querySelector('[name="slotDefaultEnd"]').value, - schedStart: slot.querySelector('[name="slotSchedStart"]').value, - schedEnd: slot.querySelector('[name="slotSchedEnd"]').value, - name: slot.querySelector('[name="slotName"]').value, - description: slot.querySelector('[name="slotDescription"]').value, - policy: getPolicyPayloadFromFlags(splittable, overlappable, invisible), + blobType: slotType, + defaultStart: slotType === BLOB_TYPES.EVENT + ? schedStart + : slot.querySelector('[name="multiDefaultStart"]')?.value || "", + defaultEnd: slotType === BLOB_TYPES.EVENT + ? schedEnd + : slot.querySelector('[name="multiDefaultEnd"]')?.value || "", + schedStart, + schedEnd, + name: slot.querySelector('[name="multiName"]')?.value?.trim() || "", + description: slot.querySelector('[name="multiDescription"]')?.value?.trim() || "", + tags: getSlotTags(slot), + dependencies: getSlotDependencies(slot), + policy: getPolicyPayloadFromFlags( + Boolean(slot.querySelector('[name="slotPolicySplittable"]')?.checked), + Boolean(slot.querySelector('[name="slotPolicyOverlappable"]')?.checked), + Boolean(slot.querySelector('[name="slotPolicyInvisible"]')?.checked), + Number(slot.querySelector('[name="slotPolicyMaxSplits"]')?.value || 0), + Number(slot.querySelector('[name="slotPolicyMinSplitDuration"]')?.value || 0), + Boolean(slot.querySelector('[name="slotPolicyRoundToGranularity"]')?.checked) + ), }); }); return slots; } -function validateWeeklySlots() { - if (!dom.weeklySlotStatus) return true; - const slots = getWeeklySlots(); +function validateMultipleSlots() { + if (!dom.multipleSlotStatus) return true; + const slots = getMultipleSlots(); if (slots.length === 0) { - dom.weeklySlotStatus.textContent = "Add at least one weekly slot."; + dom.multipleSlotStatus.textContent = "Add at least one occurrence."; return false; } - const ranges = []; for (const slot of slots) { - if (Number.isNaN(slot.day)) { - dom.weeklySlotStatus.textContent = "Weekly slots need a day of week."; + if (!slot.name) { + dom.multipleSlotStatus.textContent = "Occurrences need a name."; return false; } - const defaultStart = timeToMinutes(slot.defaultStart); - const defaultEnd = timeToMinutes(slot.defaultEnd); - const schedStart = timeToMinutes(slot.schedStart); - const schedEnd = timeToMinutes(slot.schedEnd); - if ( - defaultStart === null || - defaultEnd === null || - schedStart === null || - schedEnd === null - ) { - dom.weeklySlotStatus.textContent = "Weekly slots need valid times."; + const defaultStart = new Date(slot.defaultStart); + const defaultEnd = new Date(slot.defaultEnd); + const schedStart = new Date(slot.schedStart); + const schedEnd = new Date(slot.schedEnd); + if ([defaultStart, defaultEnd, schedStart, schedEnd].some((dt) => Number.isNaN(dt.getTime()))) { + dom.multipleSlotStatus.textContent = "Occurrences need valid date/time values."; return false; } - if (defaultEnd <= defaultStart || schedEnd <= schedStart) { - dom.weeklySlotStatus.textContent = "Weekly slots must end after they start."; + if (defaultEnd <= defaultStart) { + dom.multipleSlotStatus.textContent = "Default end must be after default start."; return false; } - if (schedStart > defaultStart || schedEnd < defaultEnd) { - dom.weeklySlotStatus.textContent = - "Schedulable range must contain default range for each slot."; + if (schedEnd <= schedStart) { + dom.multipleSlotStatus.textContent = "Schedulable end must be after schedulable start."; return false; } - const offset = dayOffsetFromMonday(slot.day); - ranges.push({ - start: offset * 1440 + schedStart, - end: offset * 1440 + schedEnd, - }); - } - const sorted = ranges.sort((a, b) => a.start - b.start); - for (let i = 0; i < sorted.length - 1; i += 1) { - if (sorted[i].end > sorted[i + 1].start) { - dom.weeklySlotStatus.textContent = "Weekly slots cannot overlap."; + if (schedStart > defaultStart || schedEnd < defaultEnd) { + dom.multipleSlotStatus.textContent = + "Schedulable range must contain default range for each occurrence."; return false; } } - dom.weeklySlotStatus.textContent = ""; + dom.multipleSlotStatus.textContent = ""; return true; } @@ -687,9 +2002,12 @@ function resetFormMode() { state.selectionScrollHandler = null; } dom.blobForm.reset(); + setBlobTypeOnContainer(nonWeeklyField, BLOB_TYPES.TASK); if (dom.recurrenceType) { dom.recurrenceType.value = "single"; } + setRecurrenceColor(null); + setRecurrenceEndValue(null); applyPolicyToForm({}); setDependencies([]); setTags([]); @@ -710,6 +2028,11 @@ function resetFormMode() { if (dom.weeklySlotStatus) { dom.weeklySlotStatus.textContent = ""; } + clearMultipleSlots(); + createMultipleSlot(); + if (dom.multipleSlotStatus) { + dom.multipleSlotStatus.textContent = ""; + } updateRecurrenceUI(); setFormMode("create"); updateStarButtons(); @@ -723,6 +2046,7 @@ function resetFormMode() { caret.classList.remove("active"); caret.style.top = ""; }); + syncDateTimeDisplays(); } function openEditForm(blob) { @@ -737,27 +2061,65 @@ function openEditForm(blob) { dom.blobForm.recurrenceName.value = blob.recurrence_payload?.recurrence_name || ""; dom.blobForm.recurrenceDescription.value = blob.recurrence_payload?.recurrence_description || ""; - dom.blobForm.blobName.value = blob.name || ""; - dom.blobForm.blobDescription.value = blob.description || ""; - setDependencies(Array.isArray(blob.dependencies) ? blob.dependencies : []); - setTags(Array.isArray(blob.tags) ? blob.tags : []); - dom.blobForm.defaultStart.value = toLocalInputValueInTimeZone( - blob.default_scheduled_timerange?.start, - appConfig.userTimeZone - ); - dom.blobForm.defaultEnd.value = toLocalInputValueInTimeZone( - blob.default_scheduled_timerange?.end, - appConfig.userTimeZone - ); - dom.blobForm.schedulableStart.value = toLocalInputValueInTimeZone( - blob.schedulable_timerange?.start, - appConfig.userTimeZone - ); - dom.blobForm.schedulableEnd.value = toLocalInputValueInTimeZone( - blob.schedulable_timerange?.end, - appConfig.userTimeZone - ); + setRecurrenceEndValue(blob.recurrence_payload?.end_date || null); + setRecurrenceColor(blob.recurrence_payload?.color || null); + if (recurrenceType === "multiple") { + setRecurrenceEndValue(null); + setRecurrenceColor(null); + } + if (recurrenceType !== "multiple") { + dom.blobForm.blobName.value = blob.name || ""; + dom.blobForm.blobDescription.value = blob.description || ""; + setDependencies(Array.isArray(blob.dependencies) ? blob.dependencies : []); + setTags(Array.isArray(blob.tags) ? blob.tags : []); + } else { + dom.blobForm.blobName.value = ""; + dom.blobForm.blobDescription.value = ""; + setDependencies([]); + setTags([]); + } + const blobTimeZone = blob.tz || appConfig.userTimeZone; + if (recurrenceType !== "date") { + dom.blobForm.defaultStart.value = toLocalInputValueInTimeZone( + blob.default_scheduled_timerange?.start, + blobTimeZone + ); + dom.blobForm.defaultEnd.value = toLocalInputValueInTimeZone( + blob.default_scheduled_timerange?.end, + blobTimeZone + ); + dom.blobForm.schedulableStart.value = toLocalInputValueInTimeZone( + blob.schedulable_timerange?.start, + blobTimeZone + ); + dom.blobForm.schedulableEnd.value = toLocalInputValueInTimeZone( + blob.schedulable_timerange?.end, + blobTimeZone + ); + setBlobTypeOnContainer( + nonWeeklyField, + isEventFromRanges( + dom.blobForm.defaultStart.value, + dom.blobForm.defaultEnd.value, + dom.blobForm.schedulableStart.value, + dom.blobForm.schedulableEnd.value + ) + ? BLOB_TYPES.EVENT + : BLOB_TYPES.TASK + ); + } + if (dom.blobForm.annualDate) { + const dateValue = toLocalInputValueInTimeZone( + blob.default_scheduled_timerange?.start, + blobTimeZone + ); + dom.blobForm.annualDate.value = dateValue ? dateValue.split("T")[0] : ""; + } + if (recurrenceType === "date") { + setBlobTypeOnContainer(nonWeeklyField, BLOB_TYPES.EVENT); + } clearWeeklySlots(); + clearMultipleSlots(); if (blob.recurrence_payload?.interval && dom.blobForm.weeklyInterval) { dom.blobForm.weeklyInterval.value = blob.recurrence_payload.interval; } @@ -766,38 +2128,134 @@ function openEditForm(blob) { applyPolicyToForm(blobs[0]?.policy || {}); const sharedName = blobs[0]?.name || ""; const sharedDescription = blobs[0]?.description || ""; + const sharedTags = Array.isArray(blobs[0]?.tags) ? blobs[0].tags : []; dom.blobForm.blobName.value = sharedName; dom.blobForm.blobDescription.value = sharedDescription || ""; + const tagsDiffer = blobs.some((item) => { + const itemTags = Array.isArray(item.tags) ? item.tags : []; + if (itemTags.length !== sharedTags.length) return true; + const sharedKeys = new Set(sharedTags.map((tag) => tagKey(tag))); + return itemTags.some((tag) => !sharedKeys.has(tagKey(tag))); + }); const hasCustom = blobs.some((item) => item.name !== sharedName || item.description !== sharedDescription) || + tagsDiffer || false; if (dom.weeklyPerSlot) { - dom.weeklyPerSlot.checked = hasCustom; + const storedPerSlot = typeof blob.recurrence_payload?.weekly_per_slot === "boolean" + ? blob.recurrence_payload.weekly_per_slot + : null; + dom.weeklyPerSlot.checked = storedPerSlot !== null ? storedPerSlot : hasCustom; } + const groupedSlots = new Map(); blobs.forEach((weeklyBlob) => { + const slotTimeZone = weeklyBlob.tz || appConfig.userTimeZone; const start = new Date(weeklyBlob.default_scheduled_timerange?.start); const end = new Date(weeklyBlob.default_scheduled_timerange?.end); const schedStart = new Date(weeklyBlob.schedulable_timerange?.start); const schedEnd = new Date(weeklyBlob.schedulable_timerange?.end); const startLocal = Number.isNaN(start.getTime()) ? "" - : formatDateTimeLocalInTimeZone(start, appConfig.userTimeZone); + : formatDateTimeLocalInTimeZone(start, slotTimeZone); const dayValue = startLocal ? weekdayIndexFromDateString(startLocal.split("T")[0]) : 1; - createWeeklySlot({ - day: dayValue, - defaultStart: timeValueFromDate(start, "09:00", appConfig.userTimeZone), - defaultEnd: timeValueFromDate(end, "10:00", appConfig.userTimeZone), - schedStart: timeValueFromDate(schedStart, "08:30", appConfig.userTimeZone), - schedEnd: timeValueFromDate(schedEnd, "10:30", appConfig.userTimeZone), + const tags = Array.isArray(weeklyBlob.tags) ? weeklyBlob.tags : []; + const normalizedTags = tags.map((tag) => tagKey(tag)).sort(); + const policyFlags = getPolicyFlagsFromPolicy(weeklyBlob.policy || {}); + const slot = { + defaultStart: timeValueFromDate(start, "09:00", slotTimeZone), + defaultEnd: timeValueFromDate(end, "10:00", slotTimeZone), + schedStart: timeValueFromDate(schedStart, "08:30", slotTimeZone), + schedEnd: timeValueFromDate(schedEnd, "10:30", slotTimeZone), name: weeklyBlob.name || "", description: weeklyBlob.description || "", + tags, policy: weeklyBlob.policy || {}, + days: [dayValue], + blobType: isEventFromRanges( + timeValueFromDate(start, "09:00", slotTimeZone), + timeValueFromDate(end, "10:00", slotTimeZone), + timeValueFromDate(schedStart, "08:30", slotTimeZone), + timeValueFromDate(schedEnd, "10:30", slotTimeZone) + ) + ? BLOB_TYPES.EVENT + : BLOB_TYPES.TASK, + }; + const key = JSON.stringify({ + defaultStart: slot.defaultStart, + defaultEnd: slot.defaultEnd, + schedStart: slot.schedStart, + schedEnd: slot.schedEnd, + name: slot.name, + description: slot.description, + tags: normalizedTags, + policy: policyFlags, + }); + const existing = groupedSlots.get(key); + if (existing) { + if (!existing.days.includes(dayValue)) { + existing.days.push(dayValue); + } + } else { + groupedSlots.set(key, slot); + } + }); + groupedSlots.forEach((slot) => { + createWeeklySlot({ + days: slot.days, + defaultStart: slot.defaultStart, + defaultEnd: slot.defaultEnd, + schedStart: slot.schedStart, + schedEnd: slot.schedEnd, + name: slot.name, + description: slot.description, + tags: slot.tags, + policy: slot.policy, + blobType: slot.blobType, }); }); setDependencies(Array.isArray(blobs[0]?.dependencies) ? blobs[0].dependencies : []); - setTags(Array.isArray(blobs[0]?.tags) ? blobs[0].tags : []); + setTags(hasCustom ? [] : sharedTags); + } else if (recurrenceType === "multiple" && blob.recurrence_payload?.blobs) { + const blobs = blob.recurrence_payload.blobs; + if (!blobs.length) { + createMultipleSlot(); + } else { + blobs.forEach((multiBlob) => { + const slotTimeZone = multiBlob.tz || appConfig.userTimeZone; + const defaultStart = toLocalInputValueInTimeZone( + multiBlob.default_scheduled_timerange?.start, + slotTimeZone + ); + const defaultEnd = toLocalInputValueInTimeZone( + multiBlob.default_scheduled_timerange?.end, + slotTimeZone + ); + const schedStart = toLocalInputValueInTimeZone( + multiBlob.schedulable_timerange?.start, + slotTimeZone + ); + const schedEnd = toLocalInputValueInTimeZone( + multiBlob.schedulable_timerange?.end, + slotTimeZone + ); + createMultipleSlot({ + defaultStart, + defaultEnd, + schedStart, + schedEnd, + name: multiBlob.name || "", + description: multiBlob.description || "", + tags: Array.isArray(multiBlob.tags) ? multiBlob.tags : [], + dependencies: Array.isArray(multiBlob.dependencies) ? multiBlob.dependencies : [], + policy: multiBlob.policy || {}, + blobType: isEventFromRanges(defaultStart, defaultEnd, schedStart, schedEnd) + ? BLOB_TYPES.EVENT + : BLOB_TYPES.TASK, + }); + }); + } } else { applyPolicyToForm(blob.policy); createWeeklySlot(); @@ -819,6 +2277,9 @@ function openEditForm(blob) { dom.blobForm.deltaValue.value = value; dom.blobForm.deltaUnit.value = unit; } + if (dom.multipleSlots && dom.multipleSlots.children.length === 0) { + createMultipleSlot(); + } state.editingRecurrenceId = blob.recurrence_id || null; state.editingRecurrenceType = recurrenceType; state.editingRecurrencePayload = blob.recurrence_payload || {}; @@ -828,20 +2289,20 @@ function openEditForm(blob) { updateStarButtons(); dom.formStatus.textContent = ""; toggleForm(true); + syncDateTimeDisplays(); } async function deleteRecurrence() { if (!state.editingRecurrenceId) return; - const confirmed = window.confirm("Delete this entire recurrence?"); + const confirmed = await confirmDialog("Delete this entire recurrence?", { + confirmText: "Delete", + cancelText: "Cancel", + destructive: true, + }); if (!confirmed) return; dom.formStatus.textContent = "Deleting recurrence..."; try { - const response = await fetch(`${API_BASE}/recurrences/${state.editingRecurrenceId}`, { - method: "DELETE", - }); - if (!response.ok) { - throw new Error("Failed to delete recurrence"); - } + await deleteRecurrenceWithUndo(state.editingRecurrenceId); dom.formStatus.textContent = "Deleted."; toggleForm(false); resetFormMode(); @@ -862,26 +2323,23 @@ async function deleteOccurrence() { dom.formStatus.textContent = "Missing occurrence start."; return; } - const confirmed = window.confirm("Delete only this occurrence?"); + const confirmed = await confirmDialog("Delete only this occurrence?", { + confirmText: "Delete", + cancelText: "Cancel", + destructive: true, + }); if (!confirmed) return; - const existing = Array.isArray(state.editingRecurrencePayload?.exclusions) - ? state.editingRecurrencePayload.exclusions - : []; - const nextExclusions = Array.from(new Set([...existing, occurrenceStart])); - const payload = { - type: state.editingRecurrenceType, - payload: { ...state.editingRecurrencePayload, exclusions: nextExclusions }, - }; dom.formStatus.textContent = "Deleting occurrence..."; try { - const response = await fetch(`${API_BASE}/recurrences/${state.editingRecurrenceId}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - if (!response.ok) { - throw new Error("Failed to delete occurrence"); - } + const blob = state.blobs.find((item) => item.recurrence_id === state.editingRecurrenceId); + await deleteOccurrenceWithUndo( + blob || { + recurrence_id: state.editingRecurrenceId, + recurrence_type: state.editingRecurrenceType, + recurrence_payload: state.editingRecurrencePayload, + schedulable_timerange: { start: occurrenceStart }, + } + ); dom.formStatus.textContent = "Deleted."; toggleForm(false); resetFormMode(); @@ -936,35 +2394,87 @@ function toggleStarOccurrence() { async function handleBlobSubmit(event) { event.preventDefault(); + console.log("Form submit clicked", { + recurrenceType: dom.blobForm.recurrenceType?.value, + blobType: dom.blobForm.blobType?.value, + }); dom.formStatus.textContent = "Saving..."; const formData = new FormData(dom.blobForm); const policyPayload = getPolicyPayloadFromForm(); const dependencies = getDependencies(); const tags = getTags(); - const baseBlob = { - name: formData.get("blobName"), - description: formData.get("blobDescription") || null, - tz: appConfig.projectTimeZone, + const recurrenceType = formData.get("recurrenceType") || "single"; + const blobType = normalizeBlobType(formData.get("blobType")); + const perSlot = recurrenceType === "weekly" && Boolean(dom.weeklyPerSlot?.checked); + const recurrenceColor = getRecurrenceColor(); + const recurrenceEnd = getRecurrenceEndValue(); + const recurrenceName = formData.get("recurrenceName"); + const recurrenceDescription = formData.get("recurrenceDescription") || null; + const schedulableStart = formData.get("schedulableStart"); + const schedulableEnd = formData.get("schedulableEnd"); + const defaultStart = blobType === BLOB_TYPES.EVENT + ? schedulableStart + : formData.get("defaultStart"); + const defaultEnd = blobType === BLOB_TYPES.EVENT + ? schedulableEnd + : formData.get("defaultEnd"); + if (!["weekly", "multiple", "date"].includes(recurrenceType)) { + const defaultStartDate = new Date(defaultStart); + const defaultEndDate = new Date(defaultEnd); + const schedStartDate = new Date(schedulableStart); + const schedEndDate = new Date(schedulableEnd); + if ( + [defaultStartDate, defaultEndDate, schedStartDate, schedEndDate].some( + (dt) => Number.isNaN(dt.getTime()) + ) + ) { + dom.formStatus.textContent = "Select valid start and end times."; + return; + } + if (defaultEndDate <= defaultStartDate) { + dom.formStatus.textContent = "Default end must be after default start."; + return; + } + if (schedEndDate <= schedStartDate) { + dom.formStatus.textContent = "Schedulable end must be after schedulable start."; + return; + } + if (schedStartDate > defaultStartDate || schedEndDate < defaultEndDate) { + dom.formStatus.textContent = + "Schedulable range must contain default range."; + return; + } + } + const blobName = recurrenceType === "single" + ? (recurrenceName || "Unnamed Blob") + : (formData.get("blobName") || "Unnamed Blob"); + const blobDescription = recurrenceType === "single" + ? recurrenceDescription + : (formData.get("blobDescription") || null); + let baseBlob = { + name: blobName, + description: blobDescription, + tz: appConfig.userTimeZone, default_scheduled_timerange: { start: toProjectIsoFromLocalInput( - formData.get("defaultStart"), + defaultStart, appConfig.userTimeZone, appConfig.projectTimeZone ), end: toProjectIsoFromLocalInput( - formData.get("defaultEnd"), + defaultEnd, appConfig.userTimeZone, appConfig.projectTimeZone ), }, schedulable_timerange: { start: toProjectIsoFromLocalInput( - formData.get("schedulableStart"), + schedulableStart, appConfig.userTimeZone, appConfig.projectTimeZone ), end: toProjectIsoFromLocalInput( - formData.get("schedulableEnd"), + schedulableEnd, appConfig.userTimeZone, appConfig.projectTimeZone ), @@ -973,7 +2483,6 @@ async function handleBlobSubmit(event) { dependencies, tags, }; - const recurrenceType = formData.get("recurrenceType") || "single"; const priorPayload = state.editingRecurrencePayload || {}; let recurrencePayload = {}; if (recurrenceType === "weekly") { @@ -984,12 +2493,14 @@ async function handleBlobSubmit(event) { } const weekStart = getWeekStart(state.anchorDate); const slots = getWeeklySlots(); - const perSlot = Boolean(dom.weeklyPerSlot?.checked); - const sharedName = formData.get("blobName"); - const sharedDescription = formData.get("blobDescription") || null; + const fallbackName = formData.get("recurrenceName") || "Unnamed Blob"; + const sharedName = perSlot ? formData.get("blobName") : (formData.get("blobName") || fallbackName); + const sharedDescription = perSlot + ? (formData.get("blobDescription") || null) + : (formData.get("blobDescription") || recurrenceDescription || null); const sharedPolicy = policyPayload; const blobsOfWeek = slots.map((slot) => { - const offset = dayOffsetFromMonday(slot.day); + const offset = dayOffsetFromSunday(slot.day); const slotDate = addDays(weekStart, offset); const slotDateValue = formatDateTimeLocalInTimeZone( slotDate, @@ -1018,7 +2529,7 @@ async function handleBlobSubmit(event) { return { name: perSlot && slot.name ? slot.name : sharedName, description: perSlot ? slot.description || null : sharedDescription, - tz: appConfig.projectTimeZone, + tz: appConfig.userTimeZone, default_scheduled_timerange: { start: defaultStart, end: defaultEnd, @@ -1029,13 +2540,16 @@ async function handleBlobSubmit(event) { }, policy: perSlot ? slot.policy : sharedPolicy, dependencies, - tags, + tags: perSlot ? slot.tags : tags, }; }); recurrencePayload = { interval: Math.max(1, Number(formData.get("weeklyInterval") || 1)), - recurrence_name: formData.get("recurrenceName") || null, - recurrence_description: formData.get("recurrenceDescription") || null, + recurrence_name: recurrenceName || null, + recurrence_description: recurrenceDescription, + end_date: recurrenceEnd, + color: recurrenceColor, + weekly_per_slot: perSlot, blobs_of_week: blobsOfWeek, }; } else if (recurrenceType === "delta") { @@ -1049,20 +2563,110 @@ async function handleBlobSubmit(event) { }; recurrencePayload = { delta_seconds: value * (unitSeconds[unit] || 86400), - recurrence_name: formData.get("recurrenceName") || null, - recurrence_description: formData.get("recurrenceDescription") || null, + recurrence_name: recurrenceName || null, + recurrence_description: recurrenceDescription, + end_date: recurrenceEnd, + color: recurrenceColor, start_blob: baseBlob, }; + } else if (recurrenceType === "multiple") { + const isValid = validateMultipleSlots(); + if (!isValid) { + dom.formStatus.textContent = "Fix occurrence errors before saving."; + return; + } + const slots = getMultipleSlots(); + const blobs = slots.map((slot) => ({ + name: slot.name, + description: slot.description || null, + tz: appConfig.userTimeZone, + default_scheduled_timerange: { + start: toProjectIsoFromLocalInput( + slot.defaultStart, + appConfig.userTimeZone, + appConfig.projectTimeZone + ), + end: toProjectIsoFromLocalInput( + slot.defaultEnd, + appConfig.userTimeZone, + appConfig.projectTimeZone + ), + }, + schedulable_timerange: { + start: toProjectIsoFromLocalInput( + slot.schedStart, + appConfig.userTimeZone, + appConfig.projectTimeZone + ), + end: toProjectIsoFromLocalInput( + slot.schedEnd, + appConfig.userTimeZone, + appConfig.projectTimeZone + ), + }, + policy: slot.policy || {}, + dependencies: slot.dependencies || [], + tags: slot.tags || [], + })); + recurrencePayload = { + recurrence_name: recurrenceName || null, + recurrence_description: recurrenceDescription, + end_date: null, + color: null, + blobs, + }; } else if (recurrenceType === "date") { + const annualDate = formData.get("annualDate")?.toString().trim() || ""; + if (!annualDate) { + dom.formStatus.textContent = "Select an annual date."; + return; + } + const datePolicy = getPolicyPayloadFromFlags( + false, + false, + true, + DEFAULT_MAX_SPLITS, + DEFAULT_MIN_SPLIT_MINUTES * 60, + false + ); + const dayStart = toProjectIsoFromLocalInput( + `${annualDate}T00:00`, + appConfig.userTimeZone, + appConfig.projectTimeZone + ); + const dayEnd = toProjectIsoFromLocalInput( + `${annualDate}T23:59`, + appConfig.userTimeZone, + appConfig.projectTimeZone + ); + baseBlob = { + ...baseBlob, + name: recurrenceName || "Untitled event", + description: recurrenceDescription, + default_scheduled_timerange: { + start: dayStart, + end: dayEnd, + }, + schedulable_timerange: { + start: dayStart, + end: dayEnd, + }, + policy: datePolicy, + dependencies: [], + }; recurrencePayload = { - recurrence_name: formData.get("recurrenceName") || null, - recurrence_description: formData.get("recurrenceDescription") || null, + recurrence_name: recurrenceName || null, + recurrence_description: recurrenceDescription, + end_date: recurrenceEnd, + color: recurrenceColor, blob: baseBlob, }; } else { recurrencePayload = { - recurrence_name: formData.get("recurrenceName") || null, - recurrence_description: formData.get("recurrenceDescription") || null, + recurrence_name: recurrenceName || null, + recurrence_description: recurrenceDescription, + end_date: recurrenceEnd, + color: recurrenceColor, blob: baseBlob, }; } @@ -1075,28 +2679,16 @@ async function handleBlobSubmit(event) { unstarred: Array.isArray(priorPayload.unstarred) ? priorPayload.unstarred : [], }; } - const payload = { type: recurrenceType, payload: recurrencePayload }; - try { const isEditing = Boolean(state.editingRecurrenceId); - const endpoint = isEditing - ? `${API_BASE}/recurrences/${state.editingRecurrenceId}` - : `${API_BASE}/recurrences`; - const response = await fetch(endpoint, { - method: isEditing ? "PUT" : "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - if (!response.ok) { - let detail = "Failed to save recurrence"; - const contentType = response.headers.get("content-type") || ""; - if (contentType.includes("application/json")) { - const data = await response.json(); - detail = data.detail || detail; - } else { - detail = (await response.text()) || detail; - } - throw new Error(detail); + if (isEditing) { + await updateRecurrence( + state.editingRecurrenceId, + recurrenceType, + recurrencePayload + ); + } else { + await createRecurrence(recurrenceType, recurrencePayload); } dom.blobForm.reset(); dom.formStatus.textContent = isEditing ? "Updated." : "Created."; @@ -1114,20 +2706,28 @@ function handleSettingsSubmit(event) { const scheduleName = formData.get("scheduleName")?.toString().trim() || ""; const subtitle = formData.get("subtitle")?.toString().trim() || ""; const granularity = Math.max(1, Number(formData.get("minuteGranularity") || 1)); - const lookaheadSeconds = Math.max(1, Number(formData.get("lookaheadSeconds") || 1)); + const finishEarlyBufferMinutes = Math.max( + 1, + Number(formData.get("finishEarlyBufferMinutes") || 1) + ); + const includeActiveOccurrences = + formData.get("includeActiveOccurrences") === "on"; + const lookaheadMinutes = Math.max(1, Number(formData.get("lookaheadMinutes") || 1)); const userTimeZone = formData.get("userTimeZone")?.toString().trim() || ""; if (userTimeZone) { try { Intl.DateTimeFormat("en-US", { timeZone: userTimeZone }); } catch (error) { - dom.settingsStatus.textContent = "Invalid timezone. Use an IANA name."; + dom.settingsStatus.textContent = "Invalid timezone. Choose a valid timezone."; return; } } appConfig.scheduleName = scheduleName || appConfig.scheduleName; appConfig.subtitle = subtitle || appConfig.subtitle; appConfig.minuteGranularity = granularity; - appConfig.lookaheadSeconds = lookaheadSeconds; + appConfig.finishEarlyBufferMinutes = finishEarlyBufferMinutes; + appConfig.includeActiveOccurrences = includeActiveOccurrences; + appConfig.lookaheadSeconds = lookaheadMinutes * 60; if (userTimeZone) { appConfig.userTimeZone = userTimeZone; } @@ -1138,14 +2738,26 @@ function handleSettingsSubmit(event) { } function handleAddClick() { + openCreateForm(BLOB_TYPES.TASK); +} + +function openCreateForm(blobType = BLOB_TYPES.TASK) { resetFormMode(); + setBlobTypeOnContainer(nonWeeklyField, blobType); toggleForm(true); - startInteractiveCreate(); + startInteractiveCreate({ blobType }); + syncDateTimeDisplays(); } function handleSettingsClick() { toggleSettings(true); + populateTimeZones(); hydrateSettingsForm(); + dom.settingsStatus.textContent = ""; +} + +function handleHelpClick() { + toggleHelp(true); } function handleCloseSettings() { @@ -1153,11 +2765,55 @@ function handleCloseSettings() { dom.settingsStatus.textContent = ""; } +function handleCloseHelp() { + toggleHelp(false); +} + function handleCloseForm() { toggleForm(false); resetFormMode(); } +function bindDraggableForm() { + if (!dom.formPanel) return; + const header = dom.formPanel.querySelector(".form-header"); + if (!header) return; + + const onPointerDown = (event) => { + if (event.button !== 0) return; + if (event.target.closest("button")) return; + const rect = dom.formPanel.getBoundingClientRect(); + isDraggingForm = true; + dragOffset = { + x: event.clientX - rect.left, + y: event.clientY - rect.top, + }; + dom.formPanel.classList.add("dragging"); + dom.formPanel.setPointerCapture?.(event.pointerId); + }; + + const onPointerMove = (event) => { + if (!isDraggingForm) return; + const nextX = Math.max(12, event.clientX - dragOffset.x); + const nextY = Math.max(12, event.clientY - dragOffset.y); + formPosition = { x: nextX, y: nextY }; + dom.formPanel.style.left = `${nextX}px`; + dom.formPanel.style.top = `${nextY}px`; + dom.formPanel.style.right = "auto"; + }; + + const stopDrag = () => { + if (!isDraggingForm) return; + isDraggingForm = false; + dom.formPanel.classList.remove("dragging"); + }; + + header.addEventListener("pointerdown", onPointerDown); + window.addEventListener("pointermove", onPointerMove); + window.addEventListener("pointerup", stopDrag); + window.addEventListener("pointercancel", stopDrag); +} + function getActiveView() { const activeViewEntry = Object.entries(dom.views).find(([, el]) => el.classList.contains("active")); if (activeViewEntry) { @@ -1216,10 +2872,21 @@ function bindFormHandlers(onRefresh) { if (dom.starOccurrenceBtn) { dom.starOccurrenceBtn.addEventListener("click", toggleStarOccurrence); } + bindDraggableForm(); + bindDateTimePickers(); dom.toggleFormBtn.addEventListener("click", handleAddClick); dom.settingsBtn.addEventListener("click", handleSettingsClick); + if (dom.helpBtn) { + dom.helpBtn.addEventListener("click", handleHelpClick); + } dom.closeSettingsBtn.addEventListener("click", handleCloseSettings); dom.settingsBackdrop.addEventListener("click", handleCloseSettings); + if (dom.closeHelpBtn) { + dom.closeHelpBtn.addEventListener("click", handleCloseHelp); + } + if (dom.helpBackdrop) { + dom.helpBackdrop.addEventListener("click", handleCloseHelp); + } dom.closeFormBtn.addEventListener("click", handleCloseForm); dom.prevDayBtn.addEventListener("click", handlePrevDay); dom.nextDayBtn.addEventListener("click", handleNextDay); @@ -1231,6 +2898,14 @@ function bindFormHandlers(onRefresh) { dom.blobForm.deltaValue.addEventListener("input", updateRecurrenceUI); dom.blobForm.deltaUnit.addEventListener("change", updateRecurrenceUI); } + if (dom.addMultipleSlotBtn) { + dom.addMultipleSlotBtn.addEventListener("click", () => { + createMultipleSlot(); + }); + } + if (dom.recurrenceEnd) { + dom.recurrenceEnd.addEventListener("change", updateRecurrenceUI); + } if (dom.dependencyInput) { dom.dependencyInput.addEventListener("input", renderDependencySuggestions); dom.dependencyInput.addEventListener("focus", renderDependencySuggestions); @@ -1316,8 +2991,13 @@ function bindFormHandlers(onRefresh) { const weeklyWrapper = dom.weeklySlots?.closest(".weekly-slots"); const wasPerSlot = weeklyWrapper?.classList.contains("per-slot"); updateRecurrenceUI(); + if (wasPerSlot && !dom.weeklyPerSlot.checked) { + const merged = collectSlotTagsUnion(); + setTags(merged); + } if (!wasPerSlot && dom.weeklyPerSlot.checked) { syncSlotPoliciesFromForm(); + syncSlotTagsFromForm(); } }); } @@ -1328,6 +3008,9 @@ function bindFormHandlers(onRefresh) { const field = dom.blobForm?.[name]; if (field) { field.addEventListener("change", () => { + if (name === "policySplittable") { + setPolicyAdvancedVisibility(field, field.checked); + } if (!dom.weeklyPerSlot?.checked) { syncSlotPoliciesFromForm(); } @@ -1337,7 +3020,27 @@ function bindFormHandlers(onRefresh) { if (dom.weeklySlots && dom.weeklySlots.children.length === 0) { createWeeklySlot(); } + if (dom.multipleSlots && dom.multipleSlots.children.length === 0) { + createMultipleSlot(); + } + bindBlobTypeToggle(nonWeeklyField); + if (settingsTabs.length) { + settingsTabs.forEach((tab) => { + tab.addEventListener("click", () => { + setActiveSettingsTab(tab.dataset.settingsTab); + }); + }); + } updateRecurrenceUI(); } -export { bindFormHandlers, handleAddClick, openEditForm, resetFormMode, toggleForm, toggleSettings }; +export { + bindFormHandlers, + handleAddClick, + openCreateForm, + openEditForm, + resetFormMode, + toggleForm, + toggleSettings, + toggleHelp, +}; diff --git a/frontend/js/history.js b/frontend/js/history.js new file mode 100644 index 0000000..f2e44a7 --- /dev/null +++ b/frontend/js/history.js @@ -0,0 +1,119 @@ +import { alertDialog } from "./popups.js"; +import { createRecurrence, deleteRecurrence, updateRecurrence } from "./api.js"; + +const STORAGE_KEY = "elastisched:history"; +const undoStack = []; +const redoStack = []; + +function persistHistory() { + try { + const payload = JSON.stringify({ undo: undoStack, redo: redoStack }); + window.sessionStorage.setItem(STORAGE_KEY, payload); + } catch (error) { + // Ignore storage errors. + } +} + +function loadHistory() { + try { + const raw = window.sessionStorage.getItem(STORAGE_KEY); + if (!raw) return; + const data = JSON.parse(raw); + undoStack.length = 0; + redoStack.length = 0; + if (Array.isArray(data.undo)) { + undoStack.push(...data.undo); + } + if (Array.isArray(data.redo)) { + redoStack.push(...data.redo); + } + } catch (error) { + // Ignore storage errors. + } +} + +function refreshCalendar() { + if (typeof window.elastischedRefresh === "function") { + window.elastischedRefresh(); + return; + } + window.dispatchEvent(new CustomEvent("elastisched:refresh")); +} + +async function runUndo(record) { + if (!record) return; + if (record.type === "update-recurrence") { + const { recurrenceId, recurrenceType, beforePayload } = record.data || {}; + if (!recurrenceId || !beforePayload) return; + await updateRecurrence(recurrenceId, recurrenceType || "single", beforePayload); + refreshCalendar(); + return; + } + if (record.type === "delete-recurrence") { + const { recurrenceType, payload } = record.data || {}; + if (!payload) return; + const created = await createRecurrence(recurrenceType || "single", payload); + record.data.restoredId = created?.id || null; + persistHistory(); + refreshCalendar(); + } +} + +async function runRedo(record) { + if (!record) return; + if (record.type === "update-recurrence") { + const { recurrenceId, recurrenceType, afterPayload } = record.data || {}; + if (!recurrenceId || !afterPayload) return; + await updateRecurrence(recurrenceId, recurrenceType || "single", afterPayload); + refreshCalendar(); + return; + } + if (record.type === "delete-recurrence") { + const { recurrenceId, restoredId } = record.data || {}; + const targetId = restoredId || recurrenceId; + if (!targetId) return; + await deleteRecurrence(targetId); + refreshCalendar(); + } +} + +function pushHistoryAction(record) { + if (!record || !record.type) return; + undoStack.push(record); + redoStack.length = 0; + persistHistory(); +} + +async function undoHistoryAction() { + const record = undoStack.pop(); + if (!record) return false; + try { + await runUndo(record); + } catch (error) { + await alertDialog(error?.message || "Undo failed."); + undoStack.push(record); + return false; + } + redoStack.push(record); + persistHistory(); + return true; +} + +async function redoHistoryAction() { + const record = redoStack.pop(); + if (!record) return false; + try { + await runRedo(record); + } catch (error) { + await alertDialog(error?.message || "Redo failed."); + redoStack.push(record); + return false; + } + undoStack.push(record); + persistHistory(); + return true; +} + +loadHistory(); + +export { pushHistoryAction, redoHistoryAction, undoHistoryAction }; diff --git a/frontend/js/popups.js b/frontend/js/popups.js new file mode 100644 index 0000000..85e262e --- /dev/null +++ b/frontend/js/popups.js @@ -0,0 +1,171 @@ +import { dom } from "./dom.js"; + +let resolver = null; +let actionMap = { confirm: true, cancel: false, alt: null }; +let lastFocusedElement = null; + +function setModalActive(active) { + if (!dom.alertModal || !dom.alertPanel) return; + if (active) { + lastFocusedElement = document.activeElement; + } + dom.alertModal.classList.toggle("active", active); + dom.alertPanel.classList.toggle("active", active); + dom.alertModal.setAttribute("aria-hidden", (!active).toString()); + document.body.classList.toggle("modal-open", active); + if (!active) { + dom.alertModal.setAttribute("inert", ""); + if (lastFocusedElement && typeof lastFocusedElement.focus === "function") { + lastFocusedElement.focus(); + } + lastFocusedElement = null; + } else { + dom.alertModal.removeAttribute("inert"); + const focusTarget = dom.alertConfirmBtn || dom.alertAltBtn || dom.alertCancelBtn; + focusTarget?.focus(); + } +} + +function closeDialog(result) { + if (!resolver) { + setModalActive(false); + return; + } + const resolve = resolver; + resolver = null; + actionMap = { confirm: true, cancel: false, alt: null }; + setModalActive(false); + resolve(result); +} + +function configureDialog({ + title, + message, + confirmText, + cancelText, + altText, + confirmVariant, + altVariant, + destructive = false, + altDestructive = false, + actionOrder = "cancel-alt-confirm", +}) { + if (dom.alertTitle) dom.alertTitle.textContent = title || "Notice"; + if (dom.alertMessage) dom.alertMessage.textContent = message || ""; + if (dom.alertConfirmBtn) { + dom.alertConfirmBtn.textContent = confirmText || "OK"; + } + if (dom.alertCancelBtn) { + if (cancelText) { + dom.alertCancelBtn.textContent = cancelText; + dom.alertCancelBtn.style.display = ""; + } else { + dom.alertCancelBtn.style.display = "none"; + } + } + if (dom.alertAltBtn) { + if (altText) { + dom.alertAltBtn.textContent = altText; + dom.alertAltBtn.style.display = ""; + } else { + dom.alertAltBtn.style.display = "none"; + } + } + if (dom.alertConfirmBtn) { + dom.alertConfirmBtn.classList.toggle("danger", destructive); + dom.alertConfirmBtn.classList.toggle("ghost", confirmVariant === "ghost"); + dom.alertConfirmBtn.classList.toggle("primary", confirmVariant !== "ghost"); + } + if (dom.alertAltBtn) { + dom.alertAltBtn.classList.toggle("danger", altDestructive); + dom.alertAltBtn.classList.toggle("ghost", altVariant !== "primary"); + dom.alertAltBtn.classList.toggle("primary", altVariant === "primary"); + } + if (dom.alertPanel) { + dom.alertPanel.dataset.actionOrder = actionOrder; + } +} + +function showDialog(options, actions) { + if (resolver) { + closeDialog(false); + } + configureDialog(options); + if (actions) { + actionMap = actions; + } + setModalActive(true); + return new Promise((resolve) => { + resolver = resolve; + }); +} + +function confirmDialog(message, options = {}) { + return showDialog( + { + title: options.title || "Confirm", + message, + confirmText: options.confirmText || "Confirm", + cancelText: options.cancelText || "Cancel", + destructive: Boolean(options.destructive), + }, + { confirm: true, cancel: false, alt: false } + ); +} + +function alertDialog(message, options = {}) { + return showDialog( + { + title: options.title || "Notice", + message, + confirmText: options.confirmText || "OK", + cancelText: null, + }, + { confirm: true, cancel: true, alt: true } + ); +} + +function choiceDialog(message, options = {}) { + return showDialog( + { + title: options.title || "Choose an action", + message, + confirmText: options.confirmText || "Option A", + altText: options.altText || "Option B", + cancelText: options.cancelText || "Cancel", + destructive: Boolean(options.destructive), + altDestructive: Boolean(options.altDestructive), + confirmVariant: options.confirmVariant, + altVariant: options.altVariant, + actionOrder: options.actionOrder || "cancel-alt-confirm", + }, + { + confirm: options.confirmValue ?? "confirm", + alt: options.altValue ?? "alt", + cancel: options.cancelValue ?? null, + } + ); +} + +function bindDialogEvents() { + if (dom.alertConfirmBtn) { + dom.alertConfirmBtn.addEventListener("click", () => closeDialog(actionMap.confirm)); + } + if (dom.alertCancelBtn) { + dom.alertCancelBtn.addEventListener("click", () => closeDialog(actionMap.cancel)); + } + if (dom.alertAltBtn) { + dom.alertAltBtn.addEventListener("click", () => closeDialog(actionMap.alt)); + } + if (dom.alertBackdrop) { + dom.alertBackdrop.addEventListener("click", () => closeDialog(actionMap.cancel)); + } + window.addEventListener("keydown", (event) => { + if (!resolver) return; + if (event.key === "Escape") { + closeDialog(actionMap.cancel); + } + }); +} + +export { alertDialog, bindDialogEvents, choiceDialog, confirmDialog }; diff --git a/frontend/js/render.js b/frontend/js/render.js index f73d0e3..25397d0 100644 --- a/frontend/js/render.js +++ b/frontend/js/render.js @@ -1,10 +1,16 @@ -import { API_BASE, appConfig, minuteGranularity, saveView, state } from "./core.js"; +import { appConfig, minuteGranularity, saveView, state } from "./core.js"; import { dom } from "./dom.js"; +import { alertDialog, choiceDialog } from "./popups.js"; +import { deleteOccurrenceWithUndo, deleteRecurrenceWithUndo } from "./actions.js"; import { addDays, clampToGranularity, formatTimeRangeInTimeZone, + getEffectiveOccurrenceRange, + getOccurrenceOverride, + getWeekStart, getTagType, + getTimeZoneParts, layoutBlocks, overlaps, startOfDay, @@ -78,6 +84,77 @@ function renderPolicyBadges(policy, { compact = false } = {}) { .join(""); } +function getRecurrenceColorClass(blob) { + const color = blob?.recurrence_payload?.color; + return color ? `palette-${color}` : ""; +} + +function getBlobTimeZone(blob) { + return blob?.tz || appConfig.userTimeZone; +} + +function getZonedParts(value, timeZone) { + if (!value) return null; + const date = value instanceof Date ? value : toDate(value); + if (!date || Number.isNaN(date.getTime())) return null; + return getTimeZoneParts(date, timeZone); +} + +function partsToDayStamp(parts) { + return Date.UTC(parts.year, parts.month - 1, parts.day); +} + +function minutesFromParts(parts) { + return parts.hour * 60 + parts.minute + (parts.second || 0) / 60; +} + +function updateNowLine(trackEl, hourHeight, viewDate) { + if (!trackEl) return; + const lineEl = trackEl.querySelector(".current-time-line"); + if (!lineEl) return; + const nowParts = getZonedParts(new Date(), appConfig.userTimeZone); + const viewParts = getZonedParts(viewDate, appConfig.userTimeZone); + if (!nowParts || !viewParts) { + lineEl.classList.remove("active"); + return; + } + if (partsToDayStamp(nowParts) !== partsToDayStamp(viewParts)) { + lineEl.classList.remove("active"); + return; + } + const minutes = Math.min(1440, Math.max(0, minutesFromParts(nowParts))); + lineEl.style.top = `${(minutes / 60) * hourHeight}px`; + lineEl.classList.add("active"); +} + +function getClampedMinutes(startParts, endParts, viewDayStamp) { + const startStamp = partsToDayStamp(startParts); + const endStamp = partsToDayStamp(endParts); + const startMin = minutesFromParts(startParts); + const endMin = minutesFromParts(endParts); + const overlapsDay = + (startStamp < viewDayStamp || (startStamp === viewDayStamp && startMin < 1440)) && + (endStamp > viewDayStamp || (endStamp === viewDayStamp && endMin > 0)); + if (!overlapsDay) return null; + const clampedStart = startStamp < viewDayStamp ? 0 : startMin; + const clampedEnd = endStamp > viewDayStamp ? 1440 : endMin; + if (clampedEnd <= clampedStart) return null; + return { startMin: clampedStart, endMin: clampedEnd }; +} + +function formatRecurrenceEnd(value) { + if (!value) return ""; + const end = toZonedDate(toDate(value), appConfig.userTimeZone); + if (!end || Number.isNaN(end.getTime())) return ""; + return end.toLocaleString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + function getInfoCard() { if (dom.infoCard) return dom.infoCard; const card = document.createElement("div"); @@ -115,19 +192,29 @@ function showInfoCard(blob, anchorRect) { if (state.infoCardLocked) return; const recurrenceName = blob.recurrence_payload?.recurrence_name; const recurrenceDescription = blob.recurrence_payload?.recurrence_description; + const recurrenceEnd = blob.recurrence_payload?.end_date; const recurrenceType = blob.recurrence_type || "single"; const recurrenceTypeLabel = recurrenceType - ? `${recurrenceType.charAt(0).toUpperCase()}${recurrenceType.slice(1)}` - : "Single"; + ? ({ + single: "Single occurrence", + multiple: "Multiple occurrence", + weekly: "Weekly cadence", + delta: "Fixed interval", + date: "Annual date", + }[recurrenceType] || `${recurrenceType.charAt(0).toUpperCase()}${recurrenceType.slice(1)}`) + : "Single occurrence"; const starred = isOccurrenceStarred(blob); const blobName = blob.name || "Untitled"; - const blobDescription = blob.description; + const blobDescription = blob.description || recurrenceDescription; const blobId = blob.id; - const timeLabel = formatTimeRangeInTimeZone( - blob.realized_timerange?.start || blob.default_scheduled_timerange?.start, - blob.realized_timerange?.end || blob.default_scheduled_timerange?.end, - appConfig.userTimeZone - ); + const effectiveRange = getEffectiveOccurrenceRange(blob); + const timeLabel = effectiveRange + ? formatTimeRangeInTimeZone( + effectiveRange.start, + effectiveRange.effectiveEnd, + getBlobTimeZone(blob) + ) + : ""; const policyBadges = renderPolicyBadges(blob.policy); const tags = Array.isArray(blob.tags) ? blob.tags.map((tag) => (typeof tag === "string" ? tag.trim() : "")).filter(Boolean) @@ -149,6 +236,14 @@ function showInfoCard(blob, anchorRect) { ${recurrenceDescription ? `
${recurrenceDescription}
` : ""} ` : ""; + const recurrenceEndLabel = formatRecurrenceEnd(recurrenceEnd); + const recurrenceEndBlock = recurrenceEndLabel + ? ` +
+
Recurrence ends
+
${recurrenceEndLabel}
+ ` + : ""; const tagBlock = tags.length > 0 ? ` @@ -180,23 +275,56 @@ function showInfoCard(blob, anchorRect) {
` : ""; + const override = getOccurrenceOverride(blob); + let addedMinutes = Number(override?.added_minutes || 0); + if (!Number.isFinite(addedMinutes)) addedMinutes = 0; + const finishDate = override?.finished_at ? toDate(override.finished_at) : null; + const hasFinish = finishDate && !Number.isNaN(finishDate.getTime()); + const finishDateLabel = + hasFinish && finishDate + ? toZonedDate(finishDate, getBlobTimeZone(blob))?.toLocaleString() || "" + : ""; + const adjustmentsBlock = + hasFinish || addedMinutes + ? ` +
+
Adjustments
+ ${hasFinish ? `
Finished at ${finishDateLabel}
` : ""} + ${addedMinutes ? `
Added time: ${addedMinutes} min
` : ""} + ` + : ""; + const showTime = recurrenceType !== "date"; + const timeBlock = showTime + ? ` +
+
Time
+
${timeLabel}
+ ` + : ""; const html = `
${blobName} - + +
${blobDescription ? `
${blobDescription}
` : ""} ${recurrenceTypeBlock} ${recurrenceBlock} + ${recurrenceEndBlock} ${tagBlock} ${idBlock} -
-
Time
-
${timeLabel}
+ ${timeBlock} + ${adjustmentsBlock} ${policyBadges ? `
Policy
${policyBadges}
` : ""} `; showInfoCardHtml(html, anchorRect); + const card = dom.infoCard; + if (card) { + card.dataset.blobId = blob.id || ""; + } } function hideInfoCard() { @@ -204,6 +332,7 @@ function hideInfoCard() { if (!card) return; card.classList.remove("active"); card.setAttribute("aria-hidden", "true"); + delete card.dataset.blobId; } function clearInfoCardLock() { @@ -212,12 +341,18 @@ function clearInfoCardLock() { state.lockedBlobId = null; if (state.view === "day") { dom.views.day?.querySelectorAll(".day-block").forEach((el) => el.classList.remove("active")); + dom.views.day + ?.querySelectorAll(".full-day-chip") + .forEach((el) => el.classList.remove("active")); const overlay = dom.views.day?.querySelector("#schedulableOverlay"); overlay?.classList.remove("active", "overflow-top", "overflow-bottom"); } else if (state.view === "week") { dom.views.week ?.querySelectorAll(".day-block") .forEach((el) => el.classList.remove("active")); + dom.views.week + ?.querySelectorAll(".full-day-chip") + .forEach((el) => el.classList.remove("active")); dom.views.week ?.querySelectorAll(".schedulable-overlay") .forEach((overlay) => overlay.classList.remove("active", "overflow-top", "overflow-bottom")); @@ -266,6 +401,8 @@ function updateCaret(caretEl, minutes, hourHeight) { async function toggleStarFromCalendar(blob) { if (!blob?.recurrence_id) return; + const wasLocked = state.infoCardLocked; + const lockedId = state.lockedBlobId; const occurrenceStart = blob.schedulable_timerange?.start; const occurrenceDate = occurrenceStart ? new Date(occurrenceStart) : null; if (!occurrenceDate || Number.isNaN(occurrenceDate.getTime())) return; @@ -304,6 +441,20 @@ async function toggleStarFromCalendar(blob) { : item ); setActive(state.view); + if (wasLocked && lockedId === blob.id) { + const viewRoot = state.view === "week" ? dom.views.week : dom.views.day; + const blockEl = + viewRoot?.querySelector(`.day-block[data-blob-id="${lockedId}"]`) || + viewRoot?.querySelector(`.full-day-chip[data-blob-id="${lockedId}"]`); + const updatedBlob = state.blobs.find((item) => item.id === lockedId); + if (blockEl && updatedBlob) { + state.infoCardLocked = false; + showInfoCard(updatedBlob, blockEl.getBoundingClientRect()); + state.infoCardLocked = true; + state.lockedBlobId = lockedId; + blockEl.classList.add("active"); + } + } try { await fetch(`${API_BASE}/recurrences/${blob.recurrence_id}`, { @@ -319,9 +470,45 @@ async function toggleStarFromCalendar(blob) { } } +async function handleInfoCardDelete(event) { + const button = event.target.closest(".info-close"); + if (!button) return; + const blobId = dom.infoCard?.dataset?.blobId || state.lockedBlobId; + if (!blobId) return; + const blob = state.blobs.find((item) => item.id === blobId); + if (!blob?.recurrence_id) return; + const choice = await choiceDialog("Delete this occurrence or the full recurrence?", { + confirmText: "Delete recurrence", + confirmValue: "recurrence", + altText: "Delete occurrence", + altValue: "occurrence", + cancelText: "Cancel", + destructive: true, + altDestructive: true, + confirmVariant: "ghost", + altVariant: "ghost", + actionOrder: "confirm-alt-cancel", + }); + if (!choice) return; + try { + if (choice === "occurrence" && blob.recurrence_type === "single") { + await deleteRecurrenceWithUndo(blob.recurrence_id); + return; + } + if (choice === "occurrence") { + await deleteOccurrenceWithUndo(blob); + return; + } + if (choice === "recurrence") { + await deleteRecurrenceWithUndo(blob.recurrence_id); + } + } catch (error) { + await alertDialog(error?.message || "Unable to delete."); + } +} + function renderDay() { const dayStart = startOfDay(state.anchorDate); - const dayEnd = addDays(dayStart, 1); const hourHeight = 44; const hours = Array.from({ length: 24 }, (_, idx) => { const hour = idx % 24; @@ -330,54 +517,72 @@ function renderDay() { return `${labelHour} ${suffix}`; }); + const fullDayEvents = []; const blocks = state.blobs .map((blob) => { - const start = toZonedDate( - toDate( - blob.realized_timerange?.start || blob.default_scheduled_timerange?.start - ), - appConfig.userTimeZone - ); - const end = toZonedDate( - toDate( - blob.realized_timerange?.end || blob.default_scheduled_timerange?.end - ), - appConfig.userTimeZone - ); - const schedStart = toZonedDate( - toDate(blob.schedulable_timerange?.start), - appConfig.userTimeZone - ); - const schedEnd = toZonedDate( - toDate(blob.schedulable_timerange?.end), - appConfig.userTimeZone - ); - if (!start || !end) return null; - if (!overlaps(dayStart, dayEnd, start, end)) return null; - const clampedStart = start < dayStart ? dayStart : start; - const clampedEnd = end > dayEnd ? dayEnd : end; - const minutes = (clampedEnd - clampedStart) / 60000; - if (minutes <= 0) return null; - const startMin = (clampedStart - dayStart) / 60000; - const endMin = (clampedEnd - dayStart) / 60000; - const showContent = start >= dayStart; + const blobTimeZone = getBlobTimeZone(blob); + const viewDayParts = getZonedParts(state.anchorDate, blobTimeZone); + const viewDayStamp = viewDayParts ? partsToDayStamp(viewDayParts) : null; + if (!viewDayStamp) return null; + const effectiveRange = getEffectiveOccurrenceRange(blob); + if (!effectiveRange) return null; + const startParts = getZonedParts(effectiveRange.start, blobTimeZone); + const endParts = getZonedParts(effectiveRange.effectiveEnd, blobTimeZone); + const schedStartParts = getZonedParts(blob.schedulable_timerange?.start, blobTimeZone); + const schedEndParts = getZonedParts(blob.schedulable_timerange?.end, blobTimeZone); + if (!startParts || !endParts) return null; + const startStamp = partsToDayStamp(startParts); + const endStamp = partsToDayStamp(endParts); + const fullDay = + startStamp === endStamp && + startParts.hour === 0 && + startParts.minute === 0 && + endParts.hour === 23 && + endParts.minute >= 59; + if (fullDay && startStamp === viewDayStamp) { + fullDayEvents.push({ + id: blob.id, + title: blob.name, + type: getTagType(blob.tags), + colorClass: getRecurrenceColorClass(blob), + starred: isOccurrenceStarred(blob), + }); + return null; + } + const clamped = getClampedMinutes(startParts, endParts, viewDayStamp); + if (!clamped) return null; + const minutes = clamped.endMin - clamped.startMin; + const showContent = partsToDayStamp(startParts) === viewDayStamp; + const baseRange = blob.realized_timerange || blob.default_scheduled_timerange || {}; + const baseStart = toDate(baseRange.start); + const baseEnd = toDate(baseRange.end); + const isAdjusted = + baseEnd && + !Number.isNaN(baseEnd.getTime()) && + effectiveRange.effectiveEnd.getTime() !== baseEnd.getTime(); return { id: blob.id, title: blob.name, time: formatTimeRangeInTimeZone( - blob.realized_timerange?.start || blob.default_scheduled_timerange.start, - blob.realized_timerange?.end || blob.default_scheduled_timerange.end, - appConfig.userTimeZone + effectiveRange.start, + effectiveRange.effectiveEnd, + blobTimeZone ), type: getTagType(blob.tags), + colorClass: getRecurrenceColorClass(blob), policy: blob.policy, starred: isOccurrenceStarred(blob), - top: (startMin / 60) * hourHeight, + top: (clamped.startMin / 60) * hourHeight, height: Math.max(18, (minutes / 60) * hourHeight), - startMin, - endMin, - schedStart, - schedEnd, + startMin: clamped.startMin, + endMin: clamped.endMin, + schedStartIso: blob.schedulable_timerange?.start || "", + schedEndIso: blob.schedulable_timerange?.end || "", + originalStartIso: baseRange.start || "", + originalEndIso: baseRange.end || "", + adjusted: Boolean(isAdjusted), + schedStartParts, + schedEndParts, showContent, }; }) @@ -387,12 +592,33 @@ function renderDay() { layoutBlocks(blocks); const hoursHtml = hours.map((hour) => `
${hour}
`).join(""); + const fullDayHtml = fullDayEvents.length + ? ` +
+
All day
+
+ ${fullDayEvents + .map( + (event) => ` +
+ + +
+ ` + ) + .join("")} +
+
+ ` + : ""; const blockHtml = blocks .map( (block) => { const policyBadges = renderPolicyBadges(block.policy, { compact: true }); return ` -
+
${ block.showContent ? ` @@ -414,18 +640,24 @@ function renderDay() { dom.views.day.innerHTML = `
${hoursHtml}
-
+
+ ${fullDayHtml} +
+
+
${blockHtml || "
No events yet
"}
+
`; const overlay = dom.views.day.querySelector("#schedulableOverlay"); + const originalOverlay = dom.views.day.querySelector("#originalOverlay"); const dayTrack = dom.views.day.querySelector(".day-track"); const selectionOverlayDefault = dom.views.day.querySelector("#selectionOverlayDefault"); const selectionOverlaySchedulable = dom.views.day.querySelector( @@ -436,6 +668,7 @@ function renderDay() { "#selectionCaretSchedulable" ); const blocksEls = dom.views.day.querySelectorAll(".day-block"); + const fullDayEls = dom.views.day.querySelectorAll(".full-day-chip"); const clearActiveBlocks = () => { blocksEls.forEach((el) => el.classList.remove("active")); @@ -453,25 +686,55 @@ function renderDay() { const applyInfoCardAndOverlay = (blockEl) => { const blob = state.blobs.find((item) => item.id === blockEl.dataset.blobId); + const blobTimeZone = getBlobTimeZone(blob); showInfoCard(blob, blockEl.getBoundingClientRect()); - const schedStart = toZonedDate( - toDate(blockEl.getAttribute("data-sched-start")), - appConfig.userTimeZone + const viewParts = getZonedParts(state.anchorDate, blobTimeZone); + if (!viewParts) return; + const viewStamp = partsToDayStamp(viewParts); + originalOverlay?.classList.remove("active"); + const schedStartParts = getZonedParts( + blockEl.getAttribute("data-sched-start"), + blobTimeZone + ); + const schedEndParts = getZonedParts( + blockEl.getAttribute("data-sched-end"), + blobTimeZone + ); + const origStartParts = getZonedParts( + blockEl.getAttribute("data-original-start"), + blobTimeZone ); - const schedEnd = toZonedDate( - toDate(blockEl.getAttribute("data-sched-end")), - appConfig.userTimeZone + const origEndParts = getZonedParts( + blockEl.getAttribute("data-original-end"), + blobTimeZone ); - if (!schedStart || !schedEnd) return; - const overlayStart = schedStart < dayStart ? dayStart : schedStart; - const overlayEnd = schedEnd > dayEnd ? dayEnd : schedEnd; - const minutes = (overlayEnd - overlayStart) / 60000; - const top = (overlayStart - dayStart) / 60000; + if (!schedStartParts || !schedEndParts) return; + const clamped = getClampedMinutes(schedStartParts, schedEndParts, viewStamp); + if (!clamped) return; + const minutes = clamped.endMin - clamped.startMin; + const top = clamped.startMin; overlay.style.top = `${(top / 60) * hourHeight}px`; overlay.style.height = `${Math.max(18, (minutes / 60) * hourHeight)}px`; - overlay.classList.toggle("overflow-top", schedStart < dayStart); - overlay.classList.toggle("overflow-bottom", schedEnd > dayEnd); + overlay.classList.toggle("overflow-top", partsToDayStamp(schedStartParts) < viewStamp); + overlay.classList.toggle("overflow-bottom", partsToDayStamp(schedEndParts) > viewStamp); overlay.classList.add("active"); + if ( + blockEl.getAttribute("data-adjusted") === "true" && + origStartParts && + origEndParts + ) { + const origClamped = getClampedMinutes(origStartParts, origEndParts, viewStamp); + if (origClamped) { + const origMinutes = origClamped.endMin - origClamped.startMin; + const origTop = origClamped.startMin; + originalOverlay.style.top = `${(origTop / 60) * hourHeight}px`; + originalOverlay.style.height = `${Math.max( + 18, + (origMinutes / 60) * hourHeight + )}px`; + originalOverlay.classList.add("active"); + } + } }; blocksEls.forEach((blockEl) => { @@ -483,11 +746,16 @@ function renderDay() { blockEl.addEventListener("mouseleave", () => { if (state.infoCardLocked) return; overlay.classList.remove("active", "overflow-top", "overflow-bottom"); + originalOverlay?.classList.remove("active"); hideInfoCard(); }); blockEl.addEventListener("click", (event) => { + if (event.shiftKey) return; if (dom.formPanel?.classList.contains("active") && !state.editingRecurrenceId) return; if (event.target.closest(".star-toggle")) return; + if (state.infoCardLocked) { + clearLockedInfoCard(); + } clearActiveBlocks(); blockEl.classList.add("active"); applyInfoCardAndOverlay(blockEl); @@ -502,19 +770,72 @@ function renderDay() { }); } }); + fullDayEls.forEach((chipEl) => { + if (!chipEl.dataset.blobId) return; + const starBtn = chipEl.querySelector(".full-day-star-toggle"); + if (starBtn) { + starBtn.addEventListener("click", (event) => { + event.stopPropagation(); + const blob = state.blobs.find((item) => item.id === chipEl.dataset.blobId); + toggleStarFromCalendar(blob); + }); + } + chipEl.addEventListener("mouseenter", () => { + if (dom.formPanel?.classList.contains("active") && !state.editingRecurrenceId) return; + if (state.infoCardLocked) return; + const blob = state.blobs.find((item) => item.id === chipEl.dataset.blobId); + showInfoCard(blob, chipEl.getBoundingClientRect()); + }); + chipEl.addEventListener("mouseleave", () => { + if (state.infoCardLocked) return; + hideInfoCard(); + }); + chipEl.addEventListener("click", (event) => { + if (event.shiftKey) return; + if (dom.formPanel?.classList.contains("active") && !state.editingRecurrenceId) return; + if (event.target.closest(".full-day-star-toggle")) return; + if (state.infoCardLocked) { + clearLockedInfoCard(); + } + fullDayEls.forEach((el) => el.classList.remove("active")); + chipEl.classList.add("active"); + const blob = state.blobs.find((item) => item.id === chipEl.dataset.blobId); + showInfoCard(blob, chipEl.getBoundingClientRect()); + state.infoCardLocked = true; + state.lockedBlobId = chipEl.dataset.blobId || null; + }); + }); if (state.activeBlockClickHandler) { document.removeEventListener("click", state.activeBlockClickHandler); } state.activeBlockClickHandler = (event) => { if (event.button !== 0) return; if (event.target.closest(".day-block")) return; + if (event.target.closest(".full-day-chip")) return; if (event.target.closest(".info-card")) return; clearActiveBlocks(); overlay.classList.remove("active", "overflow-top", "overflow-bottom"); + originalOverlay?.classList.remove("active"); hideInfoCard(); clearLockedInfoCard(); }; document.addEventListener("click", state.activeBlockClickHandler); + if (state.infoCardActionHandler) { + document.removeEventListener("click", state.infoCardActionHandler); + } + state.infoCardActionHandler = (event) => { + if (event.target.closest(".info-close")) { + handleInfoCardDelete(event); + return; + } + if (event.target.closest(".info-star-btn")) { + const blobId = dom.infoCard?.dataset?.blobId || state.lockedBlobId; + if (!blobId) return; + const blob = state.blobs.find((item) => item.id === blobId); + toggleStarFromCalendar(blob); + } + }; + document.addEventListener("click", state.infoCardActionHandler); if (state.selectionMode) { let clickStart = null; @@ -527,21 +848,24 @@ function renderDay() { }; const finalizeRange = (startMin, endMin) => { + const isEvent = state.currentBlobType === "event"; const startDate = new Date(dayStart.getTime() + startMin * 60000); const endDate = new Date(dayStart.getTime() + endMin * 60000); - if (state.selectionStep === "default") { + if (state.selectionStep === "default" && !isEvent) { state.pendingDefaultRange = { start: startDate, end: endDate }; state.selectionStep = "schedulable"; dom.formStatus.textContent = "Click start/end for schedulable range."; selectionCaretDefault.classList.remove("active"); - } else if (state.selectionStep === "schedulable") { + } else if (state.selectionStep === "schedulable" || isEvent) { state.pendingSchedulableRange = { start: startDate, end: endDate }; state.selectionMode = false; state.selectionStep = null; selectionOverlayDefault.classList.add("active"); selectionOverlaySchedulable.classList.add("active"); selectionCaretSchedulable.classList.remove("active"); - const defaultRange = state.pendingDefaultRange; + const defaultRange = state.pendingDefaultRange || (isEvent + ? state.pendingSchedulableRange + : null); const schedRange = state.pendingSchedulableRange; if (defaultRange && schedRange) { dom.blobForm.defaultStart.value = toLocalInputFromDate(defaultRange.start); @@ -549,6 +873,9 @@ function renderDay() { dom.blobForm.schedulableStart.value = toLocalInputFromDate(schedRange.start); dom.blobForm.schedulableEnd.value = toLocalInputFromDate(schedRange.end); dom.blobForm.defaultStart.dispatchEvent(new Event("change", { bubbles: true })); + dom.blobForm.defaultEnd.dispatchEvent(new Event("change", { bubbles: true })); + dom.blobForm.schedulableStart.dispatchEvent(new Event("change", { bubbles: true })); + dom.blobForm.schedulableEnd.dispatchEvent(new Event("change", { bubbles: true })); } dom.formStatus.textContent = "Ranges captured. Fill details and create."; } @@ -648,12 +975,12 @@ function renderDay() { } setDateLabel(formatDayLabel(state.anchorDate)); + updateNowLine(dayTrack, hourHeight, state.anchorDate); } function renderWeek() { - const dayOfWeek = state.anchorDate.getDay(); - const monday = addDays(state.anchorDate, dayOfWeek === 0 ? -6 : 1 - dayOfWeek); - const days = Array.from({ length: 7 }, (_, idx) => addDays(monday, idx)); + const weekStart = getWeekStart(state.anchorDate); + const days = Array.from({ length: 7 }, (_, idx) => addDays(weekStart, idx)); const hourHeight = 44; const hours = Array.from({ length: 24 }, (_, idx) => { const hour = idx % 24; @@ -662,72 +989,140 @@ function renderWeek() { return `${labelHour} ${suffix}`; }); - const columns = days - .map((date) => { - const dayStart = startOfDay(date); - const dayEnd = addDays(dayStart, 1); - const blocks = state.blobs - .map((blob) => { - const start = toZonedDate( - toDate( - blob.realized_timerange?.start || blob.default_scheduled_timerange?.start - ), - appConfig.userTimeZone - ); - const end = toZonedDate( - toDate( - blob.realized_timerange?.end || blob.default_scheduled_timerange?.end + const dayEntries = days.map((date) => { + const fullDayEvents = []; + const blocks = state.blobs + .map((blob) => { + const blobTimeZone = getBlobTimeZone(blob); + const viewParts = getZonedParts(date, blobTimeZone); + const viewStamp = viewParts ? partsToDayStamp(viewParts) : null; + if (!viewStamp) return null; + const effectiveRange = getEffectiveOccurrenceRange(blob); + if (!effectiveRange) return null; + const startParts = getZonedParts(effectiveRange.start, blobTimeZone); + const endParts = getZonedParts(effectiveRange.effectiveEnd, blobTimeZone); + const schedStartParts = getZonedParts(blob.schedulable_timerange?.start, blobTimeZone); + const schedEndParts = getZonedParts(blob.schedulable_timerange?.end, blobTimeZone); + if (!startParts || !endParts) return null; + const startStamp = partsToDayStamp(startParts); + const endStamp = partsToDayStamp(endParts); + const fullDay = + startStamp === endStamp && + startParts.hour === 0 && + startParts.minute === 0 && + endParts.hour === 23 && + endParts.minute >= 59; + if (fullDay && startStamp === viewStamp) { + fullDayEvents.push({ + id: blob.id, + title: blob.name, + type: getTagType(blob.tags), + colorClass: getRecurrenceColorClass(blob), + starred: isOccurrenceStarred(blob), + }); + return null; + } + const clamped = getClampedMinutes(startParts, endParts, viewStamp); + if (!clamped) return null; + const minutes = clamped.endMin - clamped.startMin; + const showContent = partsToDayStamp(startParts) === viewStamp; + const baseRange = blob.realized_timerange || blob.default_scheduled_timerange || {}; + const baseStart = toDate(baseRange.start); + const baseEnd = toDate(baseRange.end); + const isAdjusted = + baseEnd && + !Number.isNaN(baseEnd.getTime()) && + effectiveRange.effectiveEnd.getTime() !== baseEnd.getTime(); + return { + id: blob.id, + title: blob.name, + time: formatTimeRangeInTimeZone( + effectiveRange.start, + effectiveRange.effectiveEnd, + blobTimeZone ), - appConfig.userTimeZone - ); - const schedStart = toZonedDate( - toDate(blob.schedulable_timerange?.start), - appConfig.userTimeZone - ); - const schedEnd = toZonedDate( - toDate(blob.schedulable_timerange?.end), - appConfig.userTimeZone - ); - if (!start || !end) return null; - if (!overlaps(dayStart, dayEnd, start, end)) return null; - const clampedStart = start < dayStart ? dayStart : start; - const clampedEnd = end > dayEnd ? dayEnd : end; - const minutes = (clampedEnd - clampedStart) / 60000; - if (minutes <= 0) return null; - const startMin = (clampedStart - dayStart) / 60000; - const endMin = (clampedEnd - dayStart) / 60000; - const showContent = start >= dayStart; - return { - id: blob.id, - title: blob.name, - time: formatTimeRangeInTimeZone( - blob.realized_timerange?.start || blob.default_scheduled_timerange.start, - blob.realized_timerange?.end || blob.default_scheduled_timerange.end, - appConfig.userTimeZone - ), - type: getTagType(blob.tags), - policy: blob.policy, - starred: isOccurrenceStarred(blob), - top: (startMin / 60) * hourHeight, - height: Math.max(18, (minutes / 60) * hourHeight), - startMin, - endMin, - schedStart, - schedEnd, - showContent, - }; - }) - .filter(Boolean) - .sort((a, b) => a.top - b.top); + type: getTagType(blob.tags), + colorClass: getRecurrenceColorClass(blob), + policy: blob.policy, + starred: isOccurrenceStarred(blob), + top: (clamped.startMin / 60) * hourHeight, + height: Math.max(18, (minutes / 60) * hourHeight), + startMin: clamped.startMin, + endMin: clamped.endMin, + schedStartIso: blob.schedulable_timerange?.start || "", + schedEndIso: blob.schedulable_timerange?.end || "", + originalStartIso: baseRange.start || "", + originalEndIso: baseRange.end || "", + adjusted: Boolean(isAdjusted), + schedStartParts, + schedEndParts, + showContent, + }; + }) + .filter(Boolean) + .sort((a, b) => a.top - b.top); + + layoutBlocks(blocks); + + return { date, fullDayEvents, blocks }; + }); + + const maxFullDayCount = dayEntries.reduce( + (max, entry) => Math.max(max, entry.fullDayEvents.length), + 0 + ); - layoutBlocks(blocks); + const labelColumns = dayEntries + .map( + ({ date }) => ` +
+ +
+ ` + ) + .join(""); + + const allDayColumnsHtml = dayEntries + .map(({ fullDayEvents }) => { + const placeholderCount = Math.max(0, maxFullDayCount - fullDayEvents.length); + return ` +
+
+ ${fullDayEvents + .map( + (event) => ` +
+ + +
+ ` + ) + .join("")} + ${Array.from({ length: placeholderCount }) + .map(() => ``) + .join("")} +
+
+ `; + }) + .join(""); + const trackColumns = dayEntries + .map(({ date, blocks }) => { const blockHtml = blocks .map( (block) => { const policyBadges = renderPolicyBadges(block.policy, { compact: true }); return ` -
+
${ block.showContent ? ` @@ -747,22 +1142,15 @@ function renderWeek() { .join(""); return ` -
-
- -
+
+
+
${blockHtml || "
No events yet
"}
@@ -771,37 +1159,68 @@ function renderWeek() { .join(""); const hoursHtml = hours.map((hour) => `
${hour}
`).join(""); + const allDayRowClass = maxFullDayCount > 0 ? "" : "empty"; dom.views.week.innerHTML = `
+
+
${labelColumns}
+
All day
+
${allDayColumnsHtml}
${hoursHtml}
-
${columns}
+
${trackColumns}
`; - const weekTimeline = dom.views.week.querySelector(".week-timeline"); - const weekColumn = dom.views.week.querySelector(".week-day-column"); - const weekLabel = dom.views.week.querySelector(".week-day-label"); - if (weekTimeline && weekColumn && weekLabel) { - const labelHeight = weekLabel.getBoundingClientRect().height; - const style = getComputedStyle(weekColumn); - const gap = parseFloat(style.rowGap || style.gap || "0"); - weekTimeline.style.setProperty("--week-label-offset", `${labelHeight + gap}px`); - } - - const weekStart = startOfDay(monday); - const weekEnd = addDays(weekStart, 7); const dayColumns = Array.from(dom.views.week.querySelectorAll(".week-day-column")); + const allDayColumns = Array.from(dom.views.week.querySelectorAll(".week-all-day-column")); const dayTracks = dayColumns.map((column, index) => { - const dayStart = startOfDay(days[index]); - const dayEnd = addDays(dayStart, 1); return { track: column.querySelector(".week-day-track"), overlay: column.querySelector(".schedulable-overlay"), - dayStart, - dayEnd, + originalOverlay: column.querySelector(".original-overlay"), + dayDate: days[index], }; }); + allDayColumns.forEach((column) => { + column.querySelectorAll(".full-day-chip").forEach((chipEl) => { + if (!chipEl.dataset.blobId) return; + const starBtn = chipEl.querySelector(".full-day-star-toggle"); + if (starBtn) { + starBtn.addEventListener("click", (event) => { + event.stopPropagation(); + const blob = state.blobs.find((item) => item.id === chipEl.dataset.blobId); + toggleStarFromCalendar(blob); + }); + } + chipEl.addEventListener("mouseenter", () => { + if (dom.formPanel?.classList.contains("active") && !state.editingRecurrenceId) return; + if (state.infoCardLocked) return; + const blob = state.blobs.find((item) => item.id === chipEl.dataset.blobId); + showInfoCard(blob, chipEl.getBoundingClientRect()); + }); + chipEl.addEventListener("mouseleave", () => { + if (state.infoCardLocked) return; + hideInfoCard(); + }); + chipEl.addEventListener("click", (event) => { + if (event.shiftKey) return; + if (dom.formPanel?.classList.contains("active") && !state.editingRecurrenceId) return; + if (event.target.closest(".full-day-star-toggle")) return; + if (state.infoCardLocked) { + clearLockedInfoCard(); + } + allDayColumns.forEach((col) => + col.querySelectorAll(".full-day-chip").forEach((el) => el.classList.remove("active")) + ); + chipEl.classList.add("active"); + const blob = state.blobs.find((item) => item.id === chipEl.dataset.blobId); + showInfoCard(blob, chipEl.getBoundingClientRect()); + state.infoCardLocked = true; + state.lockedBlobId = chipEl.dataset.blobId || null; + }); + }); + }); dayColumns.forEach((column) => { column.querySelectorAll(".day-block").forEach((blockEl) => { const setLockedInfoCard = () => { @@ -811,36 +1230,61 @@ function renderWeek() { const applyInfoCardAndOverlay = () => { const blob = state.blobs.find((item) => item.id === blockEl.dataset.blobId); + const blobTimeZone = getBlobTimeZone(blob); showInfoCard(blob, blockEl.getBoundingClientRect()); - const schedStart = toZonedDate( - toDate(blockEl.getAttribute("data-sched-start")), - appConfig.userTimeZone + const schedStartParts = getZonedParts( + blockEl.getAttribute("data-sched-start"), + blobTimeZone + ); + const schedEndParts = getZonedParts( + blockEl.getAttribute("data-sched-end"), + blobTimeZone ); - const schedEnd = toZonedDate( - toDate(blockEl.getAttribute("data-sched-end")), - appConfig.userTimeZone + const origStartParts = getZonedParts( + blockEl.getAttribute("data-original-start"), + blobTimeZone ); - if (!schedStart || !schedEnd) return; - dayTracks.forEach(({ overlay, dayStart, dayEnd }) => { - const overlapStart = schedStart < dayStart ? dayStart : schedStart; - const overlapEnd = schedEnd > dayEnd ? dayEnd : schedEnd; - if (overlapEnd <= overlapStart) { + const origEndParts = getZonedParts( + blockEl.getAttribute("data-original-end"), + blobTimeZone + ); + if (!schedStartParts || !schedEndParts) return; + const schedStartStamp = partsToDayStamp(schedStartParts); + const schedEndStamp = partsToDayStamp(schedEndParts); + dayTracks.forEach(({ overlay, originalOverlay, dayDate }) => { + const viewParts = getZonedParts(dayDate, blobTimeZone); + const viewStamp = viewParts ? partsToDayStamp(viewParts) : null; + if (!viewStamp) return; + const clamped = getClampedMinutes(schedStartParts, schedEndParts, viewStamp); + if (!clamped) { overlay.classList.remove("active", "overflow-top", "overflow-bottom"); + originalOverlay?.classList.remove("active"); return; } - const minutes = (overlapEnd - overlapStart) / 60000; - const top = (overlapStart - dayStart) / 60000; + const minutes = clamped.endMin - clamped.startMin; + const top = clamped.startMin; overlay.style.top = `${(top / 60) * hourHeight}px`; overlay.style.height = `${Math.max(18, (minutes / 60) * hourHeight)}px`; - overlay.classList.toggle( - "overflow-top", - schedStart < weekStart && dayStart.getTime() === weekStart.getTime() - ); - overlay.classList.toggle( - "overflow-bottom", - schedEnd > weekEnd && dayEnd.getTime() === weekEnd.getTime() - ); + overlay.classList.toggle("overflow-top", schedStartStamp < viewStamp); + overlay.classList.toggle("overflow-bottom", schedEndStamp > viewStamp); overlay.classList.add("active"); + if ( + blockEl.getAttribute("data-adjusted") === "true" && + origStartParts && + origEndParts + ) { + const origClamped = getClampedMinutes(origStartParts, origEndParts, viewStamp); + if (origClamped) { + const origMinutes = origClamped.endMin - origClamped.startMin; + const origTop = origClamped.startMin; + originalOverlay.style.top = `${(origTop / 60) * hourHeight}px`; + originalOverlay.style.height = `${Math.max( + 18, + (origMinutes / 60) * hourHeight + )}px`; + originalOverlay.classList.add("active"); + } + } }); }; @@ -851,14 +1295,18 @@ function renderWeek() { }); blockEl.addEventListener("mouseleave", () => { if (state.infoCardLocked) return; - dayTracks.forEach(({ overlay }) => { + dayTracks.forEach(({ overlay, originalOverlay }) => { overlay.classList.remove("active", "overflow-top", "overflow-bottom"); + originalOverlay?.classList.remove("active"); }); hideInfoCard(); }); blockEl.addEventListener("click", (event) => { + if (event.shiftKey) return; if (dom.formPanel?.classList.contains("active") && !state.editingRecurrenceId) return; if (event.target.closest(".star-toggle")) return; + state.infoCardLocked = false; + state.lockedBlobId = null; dayColumns.forEach((col) => col.querySelectorAll(".day-block").forEach((el) => el.classList.remove("active")) ); @@ -882,18 +1330,32 @@ function renderWeek() { state.activeBlockClickHandler = (event) => { if (event.button !== 0) return; if (event.target.closest(".day-block")) return; + if (event.target.closest(".full-day-chip")) return; if (event.target.closest(".info-card")) return; dayColumns.forEach((col) => col.querySelectorAll(".day-block").forEach((el) => el.classList.remove("active")) ); - dayTracks.forEach(({ overlay }) => { + allDayColumns.forEach((col) => + col.querySelectorAll(".full-day-chip").forEach((el) => el.classList.remove("active")) + ); + dayTracks.forEach(({ overlay, originalOverlay }) => { overlay.classList.remove("active", "overflow-top", "overflow-bottom"); + originalOverlay?.classList.remove("active"); }); hideInfoCard(); state.infoCardLocked = false; state.lockedBlobId = null; }; document.addEventListener("click", state.activeBlockClickHandler); + if (state.infoCardActionHandler) { + document.removeEventListener("click", state.infoCardActionHandler); + } + state.infoCardActionHandler = (event) => { + if (event.target.closest(".info-close")) { + handleInfoCardDelete(event); + } + }; + document.addEventListener("click", state.infoCardActionHandler); if (state.selectionMode) { let clickStart = null; @@ -953,16 +1415,19 @@ function renderWeek() { const endDay = startOfDay(days[rangeEndCol]); const startDate = new Date(startDay.getTime() + rangeStartMin * 60000); const endDate = new Date(endDay.getTime() + rangeEndMin * 60000); - if (state.selectionStep === "default") { + const isEvent = state.currentBlobType === "event"; + if (state.selectionStep === "default" && !isEvent) { state.pendingDefaultRange = { start: startDate, end: endDate }; state.selectionStep = "schedulable"; dom.formStatus.textContent = "Click start/end for schedulable range."; clearSelectionCarets(".selection-caret.default-range"); - } else if (state.selectionStep === "schedulable") { + } else if (state.selectionStep === "schedulable" || isEvent) { state.pendingSchedulableRange = { start: startDate, end: endDate }; state.selectionMode = false; state.selectionStep = null; - const defaultRange = state.pendingDefaultRange; + const defaultRange = state.pendingDefaultRange || (isEvent + ? state.pendingSchedulableRange + : null); const schedRange = state.pendingSchedulableRange; if (defaultRange && schedRange) { dom.blobForm.defaultStart.value = toLocalInputFromDate(defaultRange.start); @@ -970,6 +1435,9 @@ function renderWeek() { dom.blobForm.schedulableStart.value = toLocalInputFromDate(schedRange.start); dom.blobForm.schedulableEnd.value = toLocalInputFromDate(schedRange.end); dom.blobForm.defaultStart.dispatchEvent(new Event("change", { bubbles: true })); + dom.blobForm.defaultEnd.dispatchEvent(new Event("change", { bubbles: true })); + dom.blobForm.schedulableStart.dispatchEvent(new Event("change", { bubbles: true })); + dom.blobForm.schedulableEnd.dispatchEvent(new Event("change", { bubbles: true })); } dom.formStatus.textContent = "Ranges captured. Fill details and create."; clearSelectionCarets(".selection-caret.schedulable-range"); @@ -1092,7 +1560,10 @@ function renderWeek() { window.addEventListener("resize", state.selectionScrollHandler); } - setDateLabel(formatWeekLabel(monday)); + setDateLabel(formatWeekLabel(weekStart)); + dayTracks.forEach(({ track, dayDate }) => { + updateNowLine(track, hourHeight, dayDate); + }); } function renderMonth() { @@ -1118,14 +1589,11 @@ function renderMonth() { const starredKeys = new Set(); state.blobs.forEach((blob) => { if (!isOccurrenceStarred(blob)) return; - const start = toZonedDate( - toDate(blob.realized_timerange?.start || blob.default_scheduled_timerange?.start), - appConfig.userTimeZone - ); - const end = toZonedDate( - toDate(blob.realized_timerange?.end || blob.default_scheduled_timerange?.end), - appConfig.userTimeZone - ); + const blobTimeZone = getBlobTimeZone(blob); + const effectiveRange = getEffectiveOccurrenceRange(blob); + if (!effectiveRange) return; + const start = toZonedDate(effectiveRange.start, blobTimeZone); + const end = toZonedDate(effectiveRange.effectiveEnd, blobTimeZone); if (!start || !end) return; if (!overlaps(gridStart, gridEnd, start, end)) return; let cursor = startOfDay(start < gridStart ? gridStart : start); @@ -1217,18 +1685,11 @@ function renderYear() { const monthEnd = new Date(year, monthStart.getMonth() + 1, 1); const events = state.blobs.filter((blob) => { if (!isOccurrenceStarred(blob)) return false; - const start = toZonedDate( - toDate( - blob.realized_timerange?.start || blob.default_scheduled_timerange?.start - ), - appConfig.userTimeZone - ); - const end = toZonedDate( - toDate( - blob.realized_timerange?.end || blob.default_scheduled_timerange?.end - ), - appConfig.userTimeZone - ); + const blobTimeZone = getBlobTimeZone(blob); + const effectiveRange = getEffectiveOccurrenceRange(blob); + if (!effectiveRange) return false; + const start = toZonedDate(effectiveRange.start, blobTimeZone); + const end = toZonedDate(effectiveRange.effectiveEnd, blobTimeZone); return start && end && overlaps(monthStart, monthEnd, start, end); }); return ` @@ -1253,7 +1714,24 @@ function renderAll() { renderYear(); } -function setActive(view) { +function updateNowIndicators() { + if (state.view === "day") { + const dayTrack = dom.views.day?.querySelector(".day-track"); + updateNowLine(dayTrack, 44, state.anchorDate); + } else if (state.view === "week") { + const columns = dom.views.week?.querySelectorAll(".week-day-column") || []; + columns.forEach((column) => { + const dateIso = column.getAttribute("data-date"); + if (!dateIso) return; + const dayDate = new Date(dateIso); + if (Number.isNaN(dayDate.getTime())) return; + const track = column.querySelector(".week-day-track"); + updateNowLine(track, 44, dayDate); + }); + } +} + +function setActive(view, options = {}) { state.view = view; saveView(view); state.infoCardLocked = false; @@ -1270,6 +1748,10 @@ function setActive(view) { dom.nextDayBtn.title = `Next ${label}`; } + if (options.deferRender) { + return; + } + if (view === "day") { renderDay(); } else if (view === "week") { @@ -1281,12 +1763,16 @@ function setActive(view) { } } -function startInteractiveCreate() { +function startInteractiveCreate(options = {}) { + const nextType = options.blobType === "event" ? "event" : state.currentBlobType || "task"; + state.currentBlobType = nextType; state.selectionMode = true; - state.selectionStep = "default"; + state.selectionStep = nextType === "event" ? "schedulable" : "default"; state.pendingDefaultRange = null; state.pendingSchedulableRange = null; - dom.formStatus.textContent = "Click start/end for default range."; + dom.formStatus.textContent = nextType === "event" + ? "Click start/end for schedulable range." + : "Click start/end for default range."; if (state.view !== "day" && state.view !== "week") { setActive("day"); } @@ -1303,4 +1789,5 @@ export { renderYear, setActive, startInteractiveCreate, + updateNowIndicators, }; diff --git a/frontend/js/utils.js b/frontend/js/utils.js index e70ef65..7db316e 100644 --- a/frontend/js/utils.js +++ b/frontend/js/utils.js @@ -4,6 +4,35 @@ function toDate(value) { return value ? new Date(value) : null; } +function getOccurrenceOverride(blob) { + if (!blob) return null; + const key = blob.schedulable_timerange?.start; + if (!key) return null; + const overrides = blob.recurrence_payload?.occurrence_overrides; + if (!overrides || typeof overrides !== "object") return null; + const override = overrides[key]; + return override && typeof override === "object" ? override : null; +} + +function getEffectiveOccurrenceRange(blob) { + if (!blob) return null; + const baseRange = blob.realized_timerange || blob.default_scheduled_timerange; + if (!baseRange?.start || !baseRange?.end) return null; + const start = toDate(baseRange.start); + const end = toDate(baseRange.end); + if (!start || !end) return null; + if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) return null; + const override = getOccurrenceOverride(blob); + let addedMinutes = Number(override?.added_minutes || 0); + if (!Number.isFinite(addedMinutes)) addedMinutes = 0; + let finishedAt = override?.finished_at ? toDate(override.finished_at) : null; + if (finishedAt && Number.isNaN(finishedAt.getTime())) finishedAt = null; + const effectiveEnd = finishedAt + ? finishedAt + : new Date(end.getTime() + addedMinutes * 60000); + return { start, end, effectiveEnd, addedMinutes, finishedAt }; +} + function getLocalTimeZone() { return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; } @@ -247,7 +276,7 @@ function shiftAnchorDate(view, anchorDate, direction) { function getWeekStart(date) { const dayOfWeek = date.getDay(); - return addDays(startOfDay(date), dayOfWeek === 0 ? -6 : 1 - dayOfWeek); + return addDays(startOfDay(date), -dayOfWeek); } function getViewRange(view, anchorDate) { @@ -257,8 +286,7 @@ function getViewRange(view, anchorDate) { } if (view === "week") { const dayOfWeek = anchorDate.getDay(); - const monday = addDays(anchorDate, dayOfWeek === 0 ? -6 : 1 - dayOfWeek); - const start = startOfDay(monday); + const start = addDays(startOfDay(anchorDate), -dayOfWeek); return { start, end: addDays(start, 7) }; } if (view === "month") { @@ -334,7 +362,10 @@ export { getTagType, getViewRange, getWeekStart, + getEffectiveOccurrenceRange, + getOccurrenceOverride, getLocalTimeZone, + getTimeZoneParts, formatDateTimeLocalInTimeZone, formatIsoInTimeZone, toProjectIsoFromLocalInput, diff --git a/integrations/TODO.txt b/integrations/TODO.txt new file mode 100644 index 0000000..b72f056 --- /dev/null +++ b/integrations/TODO.txt @@ -0,0 +1,3 @@ +TODO: + +Add integrations for google calendar here \ No newline at end of file diff --git a/src/README.md b/integrations/translations.py similarity index 100% rename from src/README.md rename to integrations/translations.py diff --git a/landing/TODO.txt b/landing/TODO.txt new file mode 100644 index 0000000..3c41ed5 --- /dev/null +++ b/landing/TODO.txt @@ -0,0 +1,3 @@ +TODO: Landing page for elastisched. + +Should have docs, tips / tricks, keyboard binds, and demos. \ No newline at end of file diff --git a/learning/TODO.txt b/learning/TODO.txt new file mode 100644 index 0000000..a69307b --- /dev/null +++ b/learning/TODO.txt @@ -0,0 +1,3 @@ +TODO: + +learning from user data + preferences to create more accurate events \ No newline at end of file diff --git a/learning/constants.py b/learning/constants.py new file mode 100644 index 0000000..7a8d5e6 --- /dev/null +++ b/learning/constants.py @@ -0,0 +1 @@ +SENTENCE_TRANSFORMER_MODEL = "all-MiniLM-L6-v2" \ No newline at end of file diff --git a/learning/embedding.py b/learning/embedding.py new file mode 100644 index 0000000..c3d7e42 --- /dev/null +++ b/learning/embedding.py @@ -0,0 +1,4 @@ +from sentence_transformers import SentenceTransformer + +from .constants import SENTENCE_TRANSFORMER_MODEL + diff --git a/learning/model.py b/learning/model.py new file mode 100644 index 0000000..e69de29 diff --git a/mcp/TODO.txt b/mcp/TODO.txt new file mode 100644 index 0000000..a9bb9cb --- /dev/null +++ b/mcp/TODO.txt @@ -0,0 +1 @@ +MCP server integration with ollama or other local model hosts \ No newline at end of file diff --git a/notes.txt b/notes.txt deleted file mode 100644 index 447702f..0000000 --- a/notes.txt +++ /dev/null @@ -1,3 +0,0 @@ -Iterating offline: - -pip install --no-index --find-links ./offline-packages . \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 4b7ed79..84319c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,25 +19,30 @@ classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", ] - -[project.optional-dependencies] -dev = ["pytest", "black"] -api = [ - "fastapi", - "uvicorn", - "SQLAlchemy", - "asyncpg", - "pydantic", - "httpx", - "pytest-asyncio", +dependencies = [ "aiosqlite", + "asyncpg", + "fastapi", "greenlet", + "pydantic", + "scikit-learn", + "scipy", + "sentence-transformers", + "SQLAlchemy", + "torch", + "tqdm", + "transformers", + "uvicorn", ] +[project.optional-dependencies] +dev = ["pytest", "black"] + [project.urls] Homepage = "https://github.com/chrissuu/elastisched" [tool.scikit-build] cmake.version = ">=3.15" build.verbose = true -cmake.source-dir = "src/elastisched/engine" +cmake.source-dir = "engine" +wheel.packages = ["core"] diff --git a/requirements.txt b/requirements.txt index 55edecb..ee20361 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,3 +19,10 @@ pytest-asyncio==0.24.0 SQLAlchemy==2.0.36 uvicorn==0.30.6 greenlet==3.0.3 +sentence-transformers==3.0.1 +torch==2.3.1 +transformers==4.44.2 +scikit-learn==1.5.2 +scipy==1.14.1 +numpy==2.1.1 +tqdm==4.66.5 diff --git a/src/elastisched/api/__init__.py b/src/elastisched/api/__init__.py deleted file mode 100644 index 7efc448..0000000 --- a/src/elastisched/api/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from elastisched.api.main import app - -__all__ = ["app"] diff --git a/src/elastisched/engine/src-crewrite/CMakeLists.txt b/src/elastisched/engine/src-crewrite/CMakeLists.txt new file mode 100644 index 0000000..e69de29 diff --git a/src/elastisched/engine/src-crewrite/container.c b/src/elastisched/engine/src-crewrite/container.c new file mode 100644 index 0000000..2c8a41c --- /dev/null +++ b/src/elastisched/engine/src-crewrite/container.c @@ -0,0 +1,82 @@ +#include "container.h" + +container_t* mk_container(size_t capacity) { + container_t* container = malloc(sizeof(container_t)); + if (!container) return NULL; + + void** data = NULL; + if (capacity != 0) { + data = malloc(capacity * sizeof(void*)); + if (!data) { + free(container); + return NULL; + } + } + + container->size = 0; + container->capacity = (!container->capacity) ? INITIAL_CONTAINER_CAPACITY : capacity; + container->data = data; + + return container; +} + +void container_free(container_t* container, void (*free_fn)(void*)) { + if (!container) return; + if (free_fn) { + for (size_t i = 0; i < container->size; ++i) { + free_fn(container->data[i]); + } + } + + free((void*)container->data); + free(container); +} + +bool container_resize(container_t* container, size_t* capacity) { + if (!container) return false; + if (capacity && *capacity < container->size) return false; + + /* if capacity is not NULL, use capacity. Otherwise, check + * if container->capacity is 0. If true, use + * ```INITIAL_CONTAINER_CAPACITY```, otherwise, double + * container capacity. */ + size_t new_capacity = capacity ? + *capacity : + ((container->capacity == 0) ? + INITIAL_CONTAINER_CAPACITY : container->capacity * 2); + + void** new_data = realloc(container->data, new_capacity * sizeof(void*)); + if (!new_data) return false; + + container->data = new_data; + container->capacity = new_capacity; + return true; +} + +bool container_append(container_t* container, void* e) { + if (!container) return false; + if (container->size == container->capacity) { + if (!container_resize(container, NULL)) return false; + } + container->data[container->size++] = e; + return true; +} + +bool container_insert(container_t* container, void* e, size_t insert_ind) { + if (!container) return false; + if (insert_ind > container->size) return false; + + if (container->size == container->capacity) { + if (!container_resize(container, NULL)) return false; + } + + if (insert_ind < container->size) { + memmove(&container->data[insert_ind + 1], + &container->data[insert_ind], + (container->size - insert_ind) * sizeof(void*)); + } + + container->data[insert_ind] = e; + container->size++; + return true; +} \ No newline at end of file diff --git a/src/elastisched/engine/src-crewrite/container.h b/src/elastisched/engine/src-crewrite/container.h new file mode 100644 index 0000000..a0e6763 --- /dev/null +++ b/src/elastisched/engine/src-crewrite/container.h @@ -0,0 +1,63 @@ +#ifndef CONTAINER_H +#define CONTAINER_H + +#include +#include +#include + +/** + * @brief Abstract data container which also adds (optional) safe dynamic array semantics + * + * container_t is the primary struct of interest here. container.h provides a + * mostly safe (and simple) API for interacting with the container_t struct. + */ + +#define INITIAL_CONTAINER_CAPACITY 16 + +typedef struct container { + size_t size; + size_t capacity; + void** data; +} container_t; + +container_t* mk_container(size_t capacity); + +/** + * @brief frees a container, optionally the elements if free_fn is not NULL + * + * @param container + */ +void container_free(container_t* container, void (*free_fn)(void*)); + +/** + * @brief Resize the container + * + * @param container + * @param capacity optional value which the function will use if it is not NULL. + * @return true if resize was successful + * @return false otherwise + */ +bool container_resize(container_t* container, size_t* capacity); + +/** + * @brief Appends to a container. Runs in O(1) amortized work. + * + * @param container + * @param e + * @return true if container[size-1] = e + * @return false otherwise (this can include memory failures, bad inputs, etc) + */ +bool container_append(container_t* container, void* e); + +/** + * @brief inserts to a container. Runs in O(n) work. + * + * @param container + * @param e + * @param insert_ind + * @return true if container[insert_index] = e + * @return false otherwise + */ +bool container_insert(container_t* container, void* e, size_t insert_ind); + +#endif diff --git a/src/elastisched/engine/src-crewrite/dll.c b/src/elastisched/engine/src-crewrite/dll.c new file mode 100644 index 0000000..8a60065 --- /dev/null +++ b/src/elastisched/engine/src-crewrite/dll.c @@ -0,0 +1,162 @@ +#include "dll.h" +#include + +typedef struct dll_node dll_node; + +struct dll_node { + dll_node* prev; + dll_node* next; + void* value; +}; + +struct dll { + dll_node* head; + dll_node* tail; + size_t size; +}; + +dll* mk_dll() { + dll* deque = malloc(sizeof(dll)); + if (!deque) return NULL; + + deque->head = NULL; + deque->tail = NULL; + deque->size = 0; + + return deque; +} + +void dll_free(dll* deque, void (*free_fn)(void *)) { + if (!deque) return; + dll_node* curr = deque->head; + while (curr != NULL) { + dll_node* temp = curr->next; + if (free_fn) free_fn(curr->value); + free(curr); + curr = temp; + } + + free(deque); +} + +dll_node* dll_head(dll* deque) { + if (!deque) return NULL; + return deque->head; +} + +dll_node* dll_tail(dll* deque) { + if (!deque) return NULL; + return deque->tail; +} + +void dll_append(dll* deque, void* e) { + if (!deque) return; + dll_node* new_tail = malloc(sizeof(dll_node)); + if (!new_tail) return; + new_tail->value = e; + if (!deque->head) { + deque->head = new_tail; + deque->tail = new_tail; + new_tail->prev = NULL; + new_tail->next = NULL; + } else { + new_tail->prev = deque->tail; + new_tail->next = NULL; + deque->tail->next = new_tail; + deque->tail = new_tail; + } + deque->size++; + return; +} + +void dll_prepend(dll* deque, void* e) { + if (!deque) return; + dll_node* new_head = malloc(sizeof(dll_node)); + if (!new_head) return; + new_head->value = e; + if (!deque->head) { + deque->head = new_head; + deque->tail = new_head; + new_head->prev = NULL; + new_head->next = NULL; + } else { + new_head->prev = NULL; + new_head->next = deque->head; + deque->head->prev = new_head; + deque->head = new_head; + } + deque->size++; + return; +} + +void* dll_popleft(dll* deque) { + if (!deque || deque->size == 0) return NULL; + + dll_node* temp = deque->head; + void* value = temp->value; + + if (deque->size == 1) { + deque->head = NULL; + deque->tail = NULL; + } else { + deque->head = deque->head->next; + deque->head->prev = NULL; + } + + deque->size--; + free(temp); + return value; +} + +void* dll_popright(dll* deque) { + if (!deque || deque->size == 0) return NULL; + + dll_node* temp = deque->tail; + void* value = temp->value; + + if (deque->size == 1) { + deque->head = NULL; + deque->tail = NULL; + } else { + deque->tail = deque->tail->prev; + deque->tail->next = NULL; + } + + deque->size--; + free(temp); + return value; +} + +size_t dll_size(dll* deque) { + return deque ? deque->size : 0; +} + +void dll_remove(dll* deque, dll_node* node) { + if (!deque || !node) return; + + if (node == deque->head) deque->head = node->next; + if (node == deque->tail) deque->tail = node->prev; + + if (node->prev) node->prev->next = node->next; + if (node->next) node->next->prev = node->prev; + + deque->size--; + free(node); +} + +dll_node* dll_next(dll_node* node) { + return node->next; +} + +dll_node* dll_prev(dll_node* node) { + return node->prev; +} + +void* dll_node_get_value(dll_node* node) { + return node->value; +} + +void dll_node_free(dll_node* node) { + free(node); + return; +} diff --git a/src/elastisched/engine/src-crewrite/dll.h b/src/elastisched/engine/src-crewrite/dll.h new file mode 100644 index 0000000..13ab9af --- /dev/null +++ b/src/elastisched/engine/src-crewrite/dll.h @@ -0,0 +1,27 @@ +#ifndef ELASTISCHED_DLL_H +#define ELASTISCHED_DLL_H + +#include + +typedef struct dll_node dll_node; +typedef struct dll dll; + +dll* mk_dll(); +void dll_free(dll* deque, void (*free_fn)(void* e)); + +dll_node* dll_head(dll* deque); +dll_node* dll_tail(dll* deque); +void dll_append(dll* deque, void* e); +void dll_prepend(dll* deque, void* e); +void* dll_popleft(dll* deque); +void* dll_popright(dll* deque); + +void dll_remove(dll* deque, dll_node* node); +dll_node* dll_next(dll_node* node); +dll_node* dll_prev(dll_node* node); +void* dll_node_get_value(dll_node* node); +void dll_node_free(dll_node* node); + +size_t dll_size(dll* deque); + +#endif diff --git a/src/elastisched/engine/src-crewrite/engine.c b/src/elastisched/engine/src-crewrite/engine.c new file mode 100644 index 0000000..918bd97 --- /dev/null +++ b/src/elastisched/engine/src-crewrite/engine.c @@ -0,0 +1,448 @@ +#include "engine.h" + +interval_t* mk_interval(sec_t low, sec_t high) { + interval_t* interval = malloc(sizeof(interval_t)); + if (!interval) return NULL; + + interval->low = low; interval->high = high; + return interval; +} + +void interval_free(interval_t* interval) { free(interval); } + +bool interval_eq(const interval_t* U, const interval_t* V) { + return (U->low == V->low) && (U->high == V->high); +} + +bool interval_overlaps(const interval_t* U, const interval_t* V) { + return !(U->high < V->low || V->high < U->low); +} + +bool interval_contains(const interval_t* U, const interval_t* V) { + return (U->low <= V->low && V->high <= U->high); +} + +sec_t interval_length(const interval_t* interval) { + return interval->high - interval->low; +} + +bool interval_is_valid(const interval_t* interval) { + return interval->high >= interval->low; +} + +/** + * @brief Scheduling policy struct, which + * defines the configurations for how a job + * may be scheduled. Each job may have its own policy. + */ +typedef struct policy { + uint8_t max_splits; /// how many times a splittable job may be split + sec_t min_split_duration; /// minimum duration that a splittable job can be split into + uint8_t scheduling_policies; /// overloaded bitfield: bit 0 = is_splittable, bit 1 = is_overlappable, bit 2 = is_invisible +} policy_t; + +policy_t *mk_policy(bool is_splittable, bool is_overlappable, bool is_invisible, + uint8_t max_splits, sec_t min_split_duration) { + policy_t* policy = malloc(sizeof(policy_t)); + if (!policy) return NULL; + + policy->max_splits = max_splits; + policy->min_split_duration = min_split_duration; + + policy->scheduling_policies = 0; + if (is_splittable) { + policy->scheduling_policies |= 1u; + } + if (is_overlappable) { + policy->scheduling_policies |= 2u; + } + if (is_invisible) { + policy->scheduling_policies |= 4u; + } + + return policy; +}; + +uint8_t policy_max_splits(policy_t *policy) { return policy->max_splits; }; + +sec_t min_split_duration(policy_t *policy) { return policy->min_split_duration; }; + +bool policy_is_splittable(policy_t* policy) { + return policy && (policy->scheduling_policies & 1u) != 0; } + +bool policy_is_overlappable(policy_t* policy) { + return policy && ((policy->scheduling_policies & 2u) >> 1) != 0; } + +bool policy_is_invisible(policy_t* policy) { + return policy && ((policy->scheduling_policies & 4u) >> 2) != 0; } + + +bool job_is_rigid(job_t *job) { + if (!job) return false; + return job->duration == interval_length(&job->schedulable_tr); +} + +typedef enum { + RED, + BLACK } +Color; + +typedef struct node node_t; + +struct node { + node_t* left; + node_t* right; + node_t* parent; + interval_t* interval; + void* value; + sec_t max; + Color color; +}; + +typedef struct interval_tMap { + node_t* root; +} interval_tMap; + +static sec_t node_max(node_t* node) { + return node ? node->max : 0; +} + +static void update_max(node_t* node) { + if (!node || !node->interval) return; + sec_t left_max = node_max(node->left); + sec_t right_max = node_max(node->right); + sec_t interval_high = node->interval->high; + + sec_t max = interval_high; + if (left_max > max) max = left_max; + if (right_max > max) max = right_max; + node->max = max; +} + +node_t* mk_leaf_node(node_t* parent, interval_t* interval, + void* value, sec_t max, Color color +) { + node_t* node = malloc(sizeof(node_t)); + if (!node) return NULL; + + node->parent = parent; + node->interval = interval; + node->value = value; + node->max = max; + node->color = color; + + node->left = NULL; + node->right = NULL; + + return node; +} + +node_t* mk_node(node_t* left, node_t* right, node_t* parent, + interval_t* interval, void* value, sec_t max, Color color +) { + node_t* node = malloc(sizeof(node_t)); + if (!node) return NULL; + + node->left = left; + node->right = right; + node->parent = parent; + node->interval = interval; + node->value = value; + node->max = max; + node->color = color; + + return node; +} + +interval_tMap* mk_intmap(node_t* root) { + interval_tMap* map = malloc(sizeof(interval_tMap)); + if (!map) return NULL; + + map->root = root; + return map; +} + +void intmap_insert(interval_tMap* map, interval_t* key, void* value) { + if (!map || !key) return; + if (!interval_is_valid(key)) return; + + if (!map->root) { + map->root = mk_leaf_node(NULL, key, value, key->high, BLACK); + return; + } + + node_t* curr = map->root; + node_t* parent = NULL; + while (curr) { + parent = curr; + if (key->low < curr->interval->low) { + curr = curr->left; + } else { + curr = curr->right; + } + } + + node_t* node = mk_leaf_node(parent, key, value, key->high, BLACK); + if (!node) return; + + if (key->low < parent->interval->low) { + parent->left = node; + } else { + parent->right = node; + } + + for (node_t* n = parent; n; n = n->parent) { + update_max(n); + } +} + +void intmap_delete(interval_tMap* map) { + return; +} + +void intmap_free(interval_tMap* map) { + if (!map) return; + if (!map->root) { + free(map); + return; + } + + size_t capacity = 32; + size_t top = 0; + node_t** stack = malloc(capacity * sizeof(node_t*)); + if (!stack) { + free(map); + return; + } + stack[top++] = map->root; + + while (top > 0) { + node_t* node = stack[--top]; + if (node->left) { + if (top == capacity) { + capacity *= 2; + node_t** new_stack = realloc(stack, capacity * sizeof(node_t*)); + if (!new_stack) break; + stack = new_stack; + } + stack[top++] = node->left; + } + if (node->right) { + if (top == capacity) { + capacity *= 2; + node_t** new_stack = realloc(stack, capacity * sizeof(node_t*)); + if (!new_stack) break; + stack = new_stack; + } + stack[top++] = node->right; + } + free(node); + } + + free(stack); + free(map); +} + +void intmap_left_rotate(interval_tMap* map, node_t* x) { + node_t* y = x->right; + x->right = y->left; + if (y->left) y->left->parent = x; + + y->parent = x->parent; + if (!x->parent) { + map->root = y; + } else if (x == x->parent->left) { + x->parent->left = y; + } else { + x->parent->right = y; + } + y->left = x; + x->parent = y; +} + +void intmap_right_rotate(interval_tMap* map, node_t* y) { + node_t* x = y->left; + + y->left = x->right; + if (x->right) x->right->parent = y; + + x->parent = y->parent; + if (!y->parent) { + map->root = x; + } else if (y == y->parent->left) { + y->parent->left = x; + } else { + y->parent->right = x; + } + + x->right = y; + y->parent = x; +} + +/** + * @brief + * + * TagSet in practice does not get very large nor updated + * often so we opt for a simple vector-based sorted set. + * + * Two tags are equivalent if they share the same name, + * not necessarily the same description. + * + * This allows for O(logn) search time and membership + * checking, O(n) set difference and O(n) time to add + * to the set. + */ + +struct tag { + char* name; + char* description; +}; + +bool tag_eq(const tag_t* U, const tag_t* V) { + return strcmp(U->name, V->name) == 0; +} + +int tag_cmp(const tag_t* U, const tag_t* V) { + int c = strcmp(U->name, V->name); + + return (c > 0) - (c < 0); +} + +uint64_t tag_hash(const tag_t* U) { + return string_hash(U ? U->name : NULL); +} + +int tag_cmp_void(const void* U, const void* V) { + return tag_cmp((const tag_t*)U, (const tag_t*)V); +} + +uint64_t tag_hash_void(const void* U) { + return tag_hash((const tag_t*)U); +} + +size_t ts_insert_index(tag_set_t* set, tag_t tag) { + size_t l = 0; + size_t r = set->size; + + while (l < r) { + size_t mid = l + (r - l) / 2; + int _cmp = tag_cmp(&tag, (tag_t*)(set->data)[mid]); + if (_cmp == 0) return mid; + if (_cmp < 0) r = mid; + else l = mid + 1; + } + return l; +} + +void ts_insert(tag_set_t* set, tag_t tag, size_t insert_ind) { + void** e_ref = malloc(sizeof(void*)); + if (!e_ref) return; + *e_ref = (void*)&tag; + + if (insert_ind < set->size) { + memmove(&set->data[insert_ind + 1], + &set->data[insert_ind], + (set->size - insert_ind) * sizeof(tag_t)); + } + + set->data[insert_ind] = e_ref; + set->size++; +} + +bool ts_add(tag_set_t* set, tag_t tag) { + void** e_ref = malloc(sizeof(void*)); + if (!e_ref) false; + *e_ref = (void*)&tag; + + size_t insert_index = ts_insert_index(set, tag); + if (insert_index < set->size && + tag_eq(&tag, (tag_t*)set->data[insert_index])) return true; + + if (set->size == set->capacity && + !container_resize(set, NULL)) + return false; + + ts_insert(set, tag, insert_index); + return true; +} + +bool ts_in(tag_set_t* set, tag_t tag) { + size_t insert_index = ts_insert_index(set, tag); + return (insert_index < set->size && + tag_eq(&tag, (tag_t*)set->data[insert_index])); +} + +tag_set_t* ts_union(tag_set_t* U, tag_set_t* V) { + if (U->size == 0) { + tag_set_t* set_union = mk_container(V->size); + if (!set_union) return NULL; + memcpy(set_union->data, V->data, V->size * sizeof(tag_t)); + set_union->size = V->size; + return set_union; + } + if (V->size == 0) { + tag_set_t* set_union= mk_container(U->size); + if (!set_union) return NULL; + memcpy(set_union->data, U->data, U->size * sizeof(tag_t)); + set_union->size = U->size; + return set_union; + } + + size_t u_ptr = 0; + size_t v_ptr = 0; + size_t insert_ind = 0; + tag_set_t* set_union = mk_container(U->size + V->size); + if (!set_union) return NULL; + + while (u_ptr < U->size && v_ptr < V->size) { + tag_t* curr_u_tag = (tag_t*)U->data[u_ptr]; + tag_t* curr_v_tag = (tag_t*)V->data[v_ptr]; + switch (tag_cmp(curr_u_tag, curr_v_tag)) { + case -1: + set_union->data[insert_ind++] = (void*)curr_u_tag; + u_ptr++; + break; + case 0: + set_union->data[insert_ind++] = (void*)curr_u_tag; + u_ptr++; v_ptr++; + break; + case 1: + set_union->data[insert_ind++] = (void*)curr_v_tag; + v_ptr++; + break; + } + } + + while (u_ptr < U->size) set_union->data[insert_ind++] = U->data[u_ptr++]; + while (v_ptr < V->size) set_union->data[insert_ind++] = V->data[v_ptr++]; + + set_union->size = insert_ind; + + return set_union; +}; + +tag_set_t* ts_intersection(tag_set_t* U, tag_set_t* V) { + tag_set_t* set_intersection = mk_container(min(U->size, V->size)); + if (!set_intersection) return NULL; + + tag_set_t* anchor = U->size > V->size ? V : U; + tag_set_t* ot = U->size > V->size ? U : V; + size_t insert_ind = 0; + for (size_t i = 0; i < anchor->size; i++) { + if (ts_in(ot, *(tag_t*)anchor->data[i])) { + set_intersection->data[insert_ind++] = anchor->data[i]; + } + } + + set_intersection->size = insert_ind; + return set_intersection; +} + +int _tag_cmp(const void* u, const void* v) { + return tag_cmp((tag_t*)u, (tag_t*)v); +} + +bool ts_is_valid(tag_set_t* set) { + return set->size <= set->capacity && + is_sorted((void*)set->data, set->size, sizeof(tag_t), &_tag_cmp); +} \ No newline at end of file diff --git a/src/elastisched/engine/src-crewrite/engine.h b/src/elastisched/engine/src-crewrite/engine.h new file mode 100644 index 0000000..0292eb7 --- /dev/null +++ b/src/elastisched/engine/src-crewrite/engine.h @@ -0,0 +1,278 @@ +#ifndef ENGINE_H +#define ENGINE_H + +#include +#include +#include +#include +#include +#include + +#include "container.h" +#include "map.h" +#include "dll.h" +#include "hash.h" +#include "utils.h" + + +/** + * ============================================================================ + * =================== ELASTISCHED CORE SCHEDULING ENGINE =================== + * ============================================================================ + * + * @note this used to be written in C++, but it was re-written in C since what I + * got out of C++ was too much machinery for something that ended up being quite + * simple to implement. + * + * The scheduling engine uses simulated annealing (see: + * https://en.wikipedia.org/wiki/Simulated_annealing) to find schedules which + * minimize some cost function. + * + * Cost functions are intentionally non-opinionated, since preference learning + * (see ```learning```) should eventually learn user preferences which cannot be + * directly implemented by code. + * + * We call these implemented by the scheduling engine as "primitive costs + * functions", and exist to ensure validity, safety, and general optimality of + * the scheduled blobs / recurrences. + * + * Currently, we implement the following costs: + * + * - illegal schedule cost + * - overlap cost + * - split cost + * + * ============================================================================ + * Illegal Schedule Cost + * ============================================================================ + * + * Illegal schedule cost is returned whenever a schedule is deemed illegal by + * the scheduler. A schedule is considered illegal if any of the following are + * true: + * + * - a blob's scheduled tr overlaps a blob's schedulable tr + * - a non overlappable blob overlaps with another blob + * - a blob's dependencies are not met + * + * @note currently, the frontend / backend of this application will not include + * dependencies outside of the lookahead range to be scheduled. This implies + * that if a dependency, ```dep```, for a blob, ```b```, is after the lookahead + * range, then the dependency will ```dep``` will not be scheduled before + * ```b```. + * + * However, the cost function will not add illegal schedule cost for missing + * dependency(ies), returning the schedule as normal. + * + * Illegal schedules are intentionally given a large constant + * (```ILLEGAL_SCHEDULE_COST```) instead of failing for two reasons: + * + * 1. Even if the schedule settles on an illegal schedule, there might be + * *legal* schedules which the scheduler has yet to find, so failing early is + * not the correct behavior. + * + * 2. Even if the schedule cannot find an illegal schedule, it should still + * return a schedule, and the frontend/backend should handle this case + * appropriately. It is not the responsibility for the scheduler to handle what + * to do in the case of illegal schedules. + * + * ============================================================================ + * Overlap Cost + * ============================================================================ + * + * Overlap cost exists to ensure that the scheduler reduces the amount of + * overlap between two overlappable blobs as much as possible. This is because + * if free time exists on the calendar, the scheduler should try to utilize this + * free time first before attempting to overlap blobs. + * + * ============================================================================ + * Split Cost + * ============================================================================ + * + * Split cost exists to ensure that the scheduler reduces the amount of + * splitting that happens for a splittable blob. This is because lots of + * splitting reduces efficiency and solution quality of the engine since more + * blobs that need to be scheduled now exists within the lookahead range. The + * hope is that implementing split cost encourages the scheduler to first look + * for optima which splits as little as possible. + * + */ + +typedef uint32_t sec_t; +typedef double cost_t; + +typedef struct interval { + sec_t low; + sec_t high; +} interval_t; + +typedef struct tag tag_t; +typedef container_t vec; +typedef vec tag_set_t; + +typedef tag_t jid_t; /// an dependency ID is a Tag with an empty description +typedef struct policy policy_t; + +typedef struct schedule schedule_t; +typedef struct jobs jobs_t; +typedef struct pair pair_t; + +typedef struct job { + sec_t duration; + interval_t schedulable_tr; + interval_t scheduled_tr; + jid_t *id; + policy_t *policy; + set *dependencies; + set *tags; +} job_t; + +pair_t *mk_pair(void *U, void *V); +void pair_free(pair_t *pair); + +//================================= +//=========== CONSTANTS =========== +//================================= +static const sec_t MINUTE = (sec_t)60; +static const sec_t HOUR_TO_MINUTES = (sec_t)60; +static const sec_t DAY_TO_HOURS = (sec_t)24; +static const sec_t WEEK_TO_DAYS = (sec_t)7; + +static const sec_t HOUR = ((sec_t)60 * MINUTE); +static const sec_t DAY = ((sec_t)24 * HOUR); +static const sec_t WEEK = ((sec_t)7 * DAY); + +//========================================== +//=========== INTERNAL CONSTANTS =========== +//========================================== + +/** + * @brief Internal constants + * + * These are constants used by elastisched's core scheduling + * engine. However, they are intentionally exposed to the + * user since these intrinsics might be useful in optimizing + * scheduling calls. + */ + +static const double ILLEGAL_SCHEDULE_COST = 1e12; +static const double EPSILON = 1e-8; +static const unsigned int DEFAULT_RNG_SEED = 1337; + +//========================================== +//========================================== +//========================================== + +interval_t* mk_interval(sec_t low, sec_t high); +void interval_free(interval_t* interval); +bool interval_eq(const interval_t* U, const interval_t* V); +bool interval_overlaps(const interval_t* U, const interval_t* V); +bool interval_contains(const interval_t* U, const interval_t* V); +sec_t interval_length(const interval_t* interval); +bool interval_is_valid(const interval_t* interval); + +/** + * @brief creates a scheduling policy configuration struct + * + * @param is_splittable + * @param is_overlappable + * @param is_invisible + * @param max_splits + * @param min_split_duration + * @return policy_t* + */ +policy_t *mk_policy(bool is_splittable, bool is_overlappable, bool is_invisible, + uint8_t max_splits, sec_t min_split_duration); +bool policy_is_splittable(policy_t *policy); +bool policy_is_overlappable(policy_t *policy); +bool policy_is_invisible(policy_t *policy); +uint8_t policy_max_splits(policy_t *policy); +sec_t min_split_duration(policy_t *policy); + +/** + * TAGS + * + * Definitions for working with the Tag and TagContainer structs. + * + * TagContainer is a simple container struct which allows for + * functions to operate on it and implement Sets and Vectors. + * + * A set of tags in practice does not get very large nor updated + * often so we opt for a simple vector-based sorted set. + * + * Two tags are equivalent if they share the same name, + * not necessarily the same description. + * + * This allows for O(logn) search time and membership + * checking, O(n) set difference and O(n) time to add + * to the set. + */ + +tag_t *mk_tag(char *name, char *description); +void tag_free(tag_t *tag); +bool tag_eq(const tag_t *U, const tag_t *V); +int tag_cmp(const tag_t *U, const tag_t *V); +uint64_t tag_hash(const tag_t *U); + +/** + * @brief Helper function for adding tag into a set + * + * @param set + * @param tag + * @return true if no memory failures, false otherwise + */ +bool ts_add(tag_set_t *set, tag_t tag); + +/** + * @brief Membership checking in O(log|```set```|) + * + * @param set + * @param tag + * @return true if ```tag``` is in ```set``` + */ +bool ts_in(tag_set_t *set, tag_t tag); + +/** + * @brief returns a new set containing the set union + * + * @param U + * @param V + * @return TagContainer* + * + * @note runtime of this is O(|U| + |V|) + */ +tag_set_t *ts_union(tag_set_t *U, tag_set_t *V); + +/** + * @brief returns a new set containing the set intersection + * + * @param U + * @param V + * @return TagContainer* + * + * @note runtime of this is O(min(|U|, |V|)*log(max(|U|, |V|))) + */ +tag_set_t *ts_intersection(tag_set_t *U, tag_set_t *V); + +/** + * @brief ensures that the tag set internals meet the appropriate + * invariants. + * + * @param set + * @return: true if the invariants are met + */ +bool ts_is_valid(tag_set_t *set); + +schedule_t *mk_schedule(); +void schedule_free(schedule_t *schedule); +void schedule_add_job(schedule_t *schedule, const job_t *job); +void schedule_clear(schedule_t *schedule); + +cost_t schedule_cost_illegal(schedule_t *schedule, sec_t granularity); +cost_t schedule_cost_overlap(schedule_t *schedule, sec_t granularity); +cost_t schedule_cost_split(schedule_t *schedule, sec_t granularity); + +schedule_t *generate_random_schedule_neighbor(schedule_t *schedule, sec_t granularity, unsigned int seed); +pair_t *_schedule_jobs(jobs_t jobs, sec_t granularity, double initial_temp, double final_temp, uint64_t num_iters); +pair_t *schedule_jobs(jobs_t jobs, sec_t granularity); + +#endif \ No newline at end of file diff --git a/src/elastisched/engine/src-crewrite/hash.h b/src/elastisched/engine/src-crewrite/hash.h new file mode 100644 index 0000000..5a8c833 --- /dev/null +++ b/src/elastisched/engine/src-crewrite/hash.h @@ -0,0 +1,32 @@ +#ifndef ELASTISCHED_HASH_H +#define ELASTISCHED_HASH_H + +#include +#include +#include + +static inline uint64_t mix64_hash(uint64_t x) { + x += 0x9e3779b97f4a7c15ULL; + x = (x ^ (x >> 30)) * 0xbf58476d1ce4e5b9ULL; + x = (x ^ (x >> 27)) * 0x94d049bb133111ebULL; + return x ^ (x >> 31); +} + +static inline bool alpha_meets_threshold(size_t num_items, size_t num_buckets) { + if (num_buckets == 0) return false; + return (num_items * 4) >= (num_buckets * 3); +} + +static inline uint64_t string_hash(const char* str) { + const uint64_t fnv_offset = 1469598103934665603ULL; + const uint64_t fnv_prime = 1099511628211ULL; + uint64_t hash = fnv_offset; + if (!str) return hash; + for (const unsigned char* p = (const unsigned char*)str; *p; p++) { + hash ^= (uint64_t)(*p); + hash *= fnv_prime; + } + return hash; +} + +#endif diff --git a/src/elastisched/engine/src-crewrite/map.c b/src/elastisched/engine/src-crewrite/map.c new file mode 100644 index 0000000..a372fd5 --- /dev/null +++ b/src/elastisched/engine/src-crewrite/map.c @@ -0,0 +1,250 @@ +#include "map.h" +#include "hash.h" +#include + +/** + * @brief Struct which manages map buckets + * in chaining hash-map. + * + * Note: size is number of occupied buckets; num_items is total entries. + */ +typedef struct bucket_vec { + size_t num_items; + container_t* data; +} bucket_vec; + +struct map { + uint64_t (*hash_fn)(const void* e); + int (*cmp_fn)(const void* u, const void* v); + void (*free_fn)(void* e); + bucket_vec* buckets; +}; + +static bucket_vec* mk_buckets(size_t capacity) { + bucket_vec* buckets = malloc(sizeof(bucket_vec)); + if (!buckets) return NULL; + + container_t* data = malloc(sizeof(container_t)); + if (!data) { + free(buckets); + return NULL; + } + + dll** bucket_data = calloc(capacity, sizeof(dll*)); + if (!bucket_data) { + free(data); + free(buckets); + return NULL; + } + + data->size = 0; + data->capacity = capacity; + data->data = (void**)bucket_data; + + buckets->num_items = 0; + buckets->data = data; + return buckets; +} + +static void free_item_value(void* value, void (*free_fn)(void*)) { + item* kv = (item*)value; + if (free_fn) free_fn(kv->value); + free(kv); +} + +static size_t bucket_index(map* dict, void* key) { + size_t h = (size_t)dict->hash_fn(key); + return mix64_hash(h) & (dict->buckets->data->capacity - 1); +} + +static bool map_insert_bucket(map* dict, void* key, void* value) { + size_t index = bucket_index(dict, key); + dll* deque = (dll*)dict->buckets->data->data[index]; + + item* kv = malloc(sizeof(item)); + if (!kv) return false; + kv->key = key; + kv->value = value; + + if (!deque) { + deque = mk_dll(); + if (!deque) { + free(kv); + return false; + } + dict->buckets->data->data[index] = deque; + dict->buckets->data->size++; + } + + dll_append(deque, (void*)kv); + dict->buckets->num_items++; + return true; +} + +static dll_node* map_find_node(map* dict, void* key) { + size_t index = bucket_index(dict, key); + dll* deque = (dll*)dict->buckets->data->data[index]; + if (!deque) return NULL; + + dll_node* curr = dll_head(deque); + while (curr) { + item* curr_item = (item*)dll_node_get_value(curr); + if (dict->cmp_fn(key, curr_item->key) == 0) return curr; + curr = dll_next(curr); + } + return NULL; +} + +static void map_rebuild(map* dict, size_t new_capacity) { + bucket_vec* new_buckets = mk_buckets(new_capacity); + if (!new_buckets) { + fprintf(stderr, "error: malloc failed during map rebuild\n"); + return; + } + + bucket_vec* old_buckets = dict->buckets; + dict->buckets = new_buckets; + + for (size_t i = 0; i < old_buckets->data->capacity; i++) { + dll* deque = (dll*)old_buckets->data->data[i]; + if (!deque) continue; + + while (dll_size(deque)) { + item* kv = (item*)dll_popleft(deque); + map_insert_bucket(dict, kv->key, kv->value); + free(kv); + } + + free(deque); + } + + free(old_buckets->data->data); + free(old_buckets->data); + free(old_buckets); +} + +map* mk_map(uint64_t (*hash_fn)(const void* e), + int (*cmp_fn)(const void* u, const void* v), + void (*free_fn)(void* e)) +{ + if (!hash_fn || !cmp_fn) return NULL; + map* dict = malloc(sizeof(map)); + if (!dict) return NULL; + + dict->buckets = mk_buckets(INITIAL_MAP_CAPACITY); + if (!dict->buckets) { + free(dict); + return NULL; + } + + dict->hash_fn = hash_fn; + dict->cmp_fn = cmp_fn; + dict->free_fn = free_fn; + + return dict; +} + +void map_free(map* dict) { + if (!dict) return; + + for (size_t i = 0; i < dict->buckets->data->capacity; i++) { + dll* deque = (dll*)dict->buckets->data->data[i]; + if (!deque) continue; + + while (dll_size(deque)) { + item* kv = (item*)dll_popleft(deque); + free_item_value(kv, dict->free_fn); + } + + free(deque); + } + + free(dict->buckets->data->data); + free(dict->buckets->data); + free(dict->buckets); + free(dict); +} + +void map_insert(map* dict, void* key, void* value) { + if (!dict) return; + + dll_node* existing = map_find_node(dict, key); + if (existing) { + item* kv = (item*)dll_node_get_value(existing); + if (dict->free_fn) dict->free_fn(kv->value); + kv->value = value; + return; + } + + if (alpha_meets_threshold(dict->buckets->num_items, dict->buckets->data->capacity)) { + map_rebuild(dict, dict->buckets->data->capacity * 2); + } + + map_insert_bucket(dict, key, value); +} + +bool map_in(map* dict, void* key) { + return map_find_node(dict, key) != NULL; +} + +void* map_get(map* dict, void* key) { + dll_node* node = map_find_node(dict, key); + if (!node) return NULL; + item* kv = (item*)dll_node_get_value(node); + return kv->value; +} + +void map_delete(map* dict, void* key) { + if (!dict) return; + size_t index = bucket_index(dict, key); + dll* deque = (dll*)dict->buckets->data->data[index]; + if (!deque) return; + + dll_node* curr = dll_head(deque); + while (curr) { + item* curr_item = (item*)dll_node_get_value(curr); + if (dict->cmp_fn(key, curr_item->key) == 0) { + free_item_value(curr_item, dict->free_fn); + dll_remove(deque, curr); + dict->buckets->num_items--; + if (dll_size(deque) == 0) { + dict->buckets->data->data[index] = NULL; + dict->buckets->data->size--; + free(deque); + } + return; + } + curr = dll_next(curr); + } +} + +size_t map_size(map* dict) { + return dict ? dict->buckets->num_items : 0; +} + +vec_items_t* map_items(map* dict) { + if (!dict) return NULL; + vec_items_t* items = malloc(sizeof(vec_items_t)); + if (!items) return NULL; + + items->size = 0; + items->capacity = 0; + items->data = NULL; + + for (size_t i = 0; i < dict->buckets->data->capacity; i++) { + dll* deque = (dll*)dict->buckets->data->data[i]; + if (!deque) continue; + + dll_node* curr = dll_head(deque); + while (curr) { + item* curr_item = (item*)dll_node_get_value(curr); + if (!container_append(items, curr_item)) { + container_free(items, NULL); + return NULL; + } + curr = dll_next(curr); + } + } + + return items; +} diff --git a/src/elastisched/engine/src-crewrite/map.h b/src/elastisched/engine/src-crewrite/map.h new file mode 100644 index 0000000..dccbd9b --- /dev/null +++ b/src/elastisched/engine/src-crewrite/map.h @@ -0,0 +1,36 @@ +#ifndef ELASTISCHED_MAP_H +#define ELASTISCHED_MAP_H + +#include +#include +#include + +#include "dll.h" +#include "container.h" + +typedef struct map map; +typedef struct map set; + +typedef struct item { + void* key; + void* value; +} item; + +#define INITIAL_MAP_CAPACITY 32 + +typedef container_t vec_items_t; + +map* mk_map(uint64_t (*hash_fn)(const void* e), + int (*cmp_fn)(const void* u, const void* v), + void (*free_fn)(void* e)); + +void map_free(map* dict); + +void map_insert(map* dict, void* key, void* value); +bool map_in(map* dict, void* key); +void* map_get(map* dict, void* key); +void map_delete(map* dict, void* key); +size_t map_size(map* dict); +vec_items_t* map_items(map* dict); + +#endif diff --git a/src/elastisched/engine/src-crewrite/schedule.h b/src/elastisched/engine/src-crewrite/schedule.h new file mode 100644 index 0000000..2108395 --- /dev/null +++ b/src/elastisched/engine/src-crewrite/schedule.h @@ -0,0 +1,17 @@ +#ifndef ELASTISCHED_SCHEDULE_H +#define ELASTISCHED_SCHEDULE_H + +#include "job.h" +#include "utils.h" +#include "vec.h" + +typedef struct Schedule { + JobVec* scheduled_jobs; +} Schedule; + + +void schedule_add_job(Schedule* schedule, const Job* job); +void schedule_clear(Schedule* schedule); + + +#endif diff --git a/src/elastisched/engine/src-crewrite/topo_sort.c b/src/elastisched/engine/src-crewrite/topo_sort.c new file mode 100644 index 0000000..49e8860 --- /dev/null +++ b/src/elastisched/engine/src-crewrite/topo_sort.c @@ -0,0 +1,215 @@ +#include "topo_sort.h" +#include "hash.h" +#include + +#define id_hash tag_hash_void +#define id_cmp tag_cmp_void + +static DependencyViolationContainer* mk_violation_container(size_t capacity) { + DependencyViolationContainer* container = malloc(sizeof(DependencyViolationContainer)); + if (!container) return NULL; + container->dependency_violations = NULL; + container->size = 0; + container->capacity = 0; + + if (capacity == 0) return container; + container->dependency_violations = calloc(capacity, sizeof(DependencyViolation)); + if (!container->dependency_violations) { + free(container); + return NULL; + } + container->capacity = capacity; + return container; +} + +static bool violation_container_push(DependencyViolationContainer* container, + DependencyViolation violation) { + if (container->size == container->capacity) { + size_t new_capacity = container->capacity == 0 ? 4 : container->capacity * 2; + DependencyViolation* new_data = realloc(container->dependency_violations, + new_capacity * sizeof(DependencyViolation)); + if (!new_data) return false; + container->dependency_violations = new_data; + container->capacity = new_capacity; + } + + container->dependency_violations[container->size++] = violation; + return true; +} + +static void free_tag_container(void* value) { + TagContainer* container = (TagContainer*)value; + if (!container) return; + free(container->data); + free(container); +} + +static void free_violation_container(DependencyViolationContainer* container) { + if (!container) return; + for (size_t i = 0; i < container->size; i++) { + tag_container_free(container->dependency_violations[i].violated_dependencies); + } + free(container->dependency_violations); + free(container); +} + +DependencyCheckResult* check_dependency_violations(const Schedule* schedule) { + DependencyCheckResult* result = malloc(sizeof(DependencyCheckResult)); + if (!result) return NULL; + result->has_violations = false; + result->violations = NULL; + result->has_cyclic_dependencies = false; + + if (!schedule || !schedule->scheduled_jobs || schedule->scheduled_jobs->size == 0) { + return result; + } + + JobVec* scheduled_jobs = schedule->scheduled_jobs; + + map* index_map = mk_map(&id_hash, &id_cmp, free); + if (!index_map) return result; + + for (size_t i = 0; i < scheduled_jobs->size; i++) { + size_t* index = malloc(sizeof(size_t)); + if (!index) continue; + *index = i; + map_insert(index_map, (void*)&scheduled_jobs->data[i].id, (void*)index); + } + + DependencyViolationContainer* violations = mk_violation_container(4); + if (!violations) { + map_free(index_map); + return result; + } + + for (size_t i = 0; i < scheduled_jobs->size; i++) { + Job* job = &scheduled_jobs->data[i]; + DependencyContainer* deps = job->dependency_set; + if (!deps || deps->size == 0) continue; + + TagContainer* violated = mk_tag_container(0); + if (!violated) continue; + + for (size_t j = 0; j < deps->size; j++) { + ID* dep_id = &deps->data[j]; + size_t* dep_index = (size_t*)map_get(index_map, (void*)dep_id); + if (!dep_index) continue; + if (*dep_index > i) { + tv_pushback(violated, *dep_id); + } + } + + if (violated->size > 0) { + DependencyViolation violation = { + .job_id = job->id, + .violated_dependencies = violated + }; + if (!violation_container_push(violations, violation)) { + tag_container_free(violated); + } + } else { + tag_container_free(violated); + } + } + + if (violations->size > 0) { + result->has_violations = true; + result->violations = violations; + } else { + free_violation_container(violations); + } + + map_free(index_map); + + map* adj_list = mk_map(&id_hash, &id_cmp, free_tag_container); + map* in_degree = mk_map(&id_hash, &id_cmp, free); + if (!adj_list || !in_degree) { + map_free(adj_list); + map_free(in_degree); + return result; + } + + for (size_t i = 0; i < scheduled_jobs->size; i++) { + ID* job_id = &scheduled_jobs->data[i].id; + TagContainer* neighbors = mk_tag_container(0); + size_t* degree = calloc(1, sizeof(size_t)); + if (!neighbors || !degree) { + free_tag_container(neighbors); + free(degree); + continue; + } + map_insert(adj_list, (void*)job_id, (void*)neighbors); + map_insert(in_degree, (void*)job_id, (void*)degree); + } + + for (size_t i = 0; i < scheduled_jobs->size; i++) { + Job* job = &scheduled_jobs->data[i]; + ID* job_id = &job->id; + DependencyContainer* deps = job->dependency_set; + if (!deps) continue; + + for (size_t j = 0; j < deps->size; j++) { + ID* dep_id = &deps->data[j]; + if (!map_in(adj_list, (void*)dep_id)) continue; + TagContainer* neighbors = (TagContainer*)map_get(adj_list, (void*)dep_id); + if (!neighbors) continue; + tv_pushback(neighbors, *job_id); + + size_t* degree = (size_t*)map_get(in_degree, (void*)job_id); + if (degree) (*degree)++; + } + } + + dll* queue = mk_dll(); + TagContainer* topo_order = mk_tag_container(scheduled_jobs->size); + if (!queue || !topo_order) { + dll_free(queue, NULL); + tag_container_free(topo_order); + map_free(adj_list); + map_free(in_degree); + return result; + } + + vec_items* items = map_items(in_degree); + if (items) { + for (size_t i = 0; i < items->size; i++) { + if (*(size_t*)items->data[i]->value == 0) { + dll_append(queue, items->data[i]->key); + } + } + } + + while (dll_size(queue)) { + ID* curr_id = (ID*)dll_popleft(queue); + if (!curr_id) continue; + tv_pushback(topo_order, *curr_id); + + TagContainer* neighbors = (TagContainer*)map_get(adj_list, (void*)curr_id); + if (!neighbors) continue; + + for (size_t i = 0; i < neighbors->size; i++) { + ID* neighbor_id = &neighbors->data[i]; + size_t* degree = (size_t*)map_get(in_degree, (void*)neighbor_id); + if (!degree || *degree == 0) continue; + (*degree)--; + if (*degree == 0) { + dll_append(queue, neighbor_id); + } + } + } + + if (topo_order->size < scheduled_jobs->size) { + result->has_cyclic_dependencies = true; + } + + if (items) { + free(items->data); + free(items); + } + dll_free(queue, NULL); + tag_container_free(topo_order); + map_free(adj_list); + map_free(in_degree); + + return result; +} diff --git a/src/elastisched/engine/src-crewrite/topo_sort.h b/src/elastisched/engine/src-crewrite/topo_sort.h new file mode 100644 index 0000000..c17306a --- /dev/null +++ b/src/elastisched/engine/src-crewrite/topo_sort.h @@ -0,0 +1,32 @@ +#ifndef ELASTISCHED_TOPO_SORT_H +#define ELASTISCHED_TOPO_SORT_H + +#include +#include +#include "constants.h" +#include "dll.h" +#include "map.h" +#include "tag.h" +#include "utils.h" +#include "schedule.h" + +typedef struct DependencyViolation { + ID job_id; + DependencyContainer* violated_dependencies; +} DependencyViolation; + +typedef struct DependencyViolationContainer { + DependencyViolation* dependency_violations; + size_t size; + size_t capacity; +} DependencyViolationContainer; + +typedef struct DependencyCheckResult { + bool has_violations; + DependencyViolationContainer* violations; + bool has_cyclic_dependencies; +} DependencyCheckResult; + +DependencyCheckResult* check_dependency_violations(const Schedule* schedule); + +#endif diff --git a/src/elastisched/engine/src-crewrite/utils.h b/src/elastisched/engine/src-crewrite/utils.h new file mode 100644 index 0000000..0edfe2d --- /dev/null +++ b/src/elastisched/engine/src-crewrite/utils.h @@ -0,0 +1,28 @@ +#ifndef ELASTISCHED_UTILS_H +#define ELASTISCHED_UTILS_H + +#include +#include + +size_t min(size_t u, size_t v) { + return u > v ? v : u; +} + +size_t max(size_t u, size_t v) { + return u > v ? u: v; +} + +bool is_sorted(const void* base, size_t count, size_t elem_size, + int (*cmp)(const void*, const void*)) { + if (count < 2) return true; + + const char* bytes = (const char*)base; + for (size_t i = 1; i < count; i++) { + const void* prev = bytes + (i - 1) * elem_size; + const void* curr = bytes + i * elem_size; + if (cmp(prev, curr) > 0) return false; + } + return true; +} + +#endif diff --git a/test.py b/test.py deleted file mode 100644 index 4e17779..0000000 --- a/test.py +++ /dev/null @@ -1,51 +0,0 @@ -import engine -from elastisched.blob import Blob -from datetime import datetime -from datetime import timezone - -def main(): - # Create a Tag - tag = engine.Tag("work") - print("Tag name:", tag.name) - - # Create a Policy - policy = engine.Policy(0, 0, 0) - print("Policy is splittable?", policy.isSplittable()) - - # Create a TimeRange - tr_schedulable = engine.TimeRange(0, 60 * 60) # 0 to 1 hour - tr_scheduled = engine.TimeRange(0, 60 * 30) # 0 to 30 min - - # Create a Job - job = engine.Job( - 60 * 30, # duration: 30 min - tr_schedulable, - tr_scheduled, - "job1", - policy, - set(), # dependencies - {tag}, # tags - ) - - # Create a Schedule and add the job - sched = engine.Schedule() - sched.addJob(job) - print("Number of jobs in schedule:", len(sched.scheduledJobs)) - - # Run the engine.function - jobs = [job] - result, cost_history = engine.schedule_jobs( - jobs, 60 * 15, 1000.0, 1.0, 100000000 - ) # granularity: 15 min, start_epoch: 0 - - for e in result.scheduledJobs: - print(e) - - blob = Blob.from_job(result.scheduledJobs[0], datetime.now(), "TestBlob", "Hello world!", timezone.utc) - print(f"Blob id: {blob.get_id()}") - print(f"Blob policy: {blob.policy.isSplittable()}") - print(cost_history) - - -if __name__ == "__main__": - main() diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..ed8e302 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +BACKEND = ROOT / "backend" +SRC = ROOT / "src" + +for path in (BACKEND, SRC): + if path.exists(): + sys.path.insert(0, str(path)) diff --git a/tests/test_api.py b/tests/test_api.py index 040dd6f..4ac4edf 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -3,16 +3,17 @@ from datetime import datetime, timedelta, timezone import pytest +import pytest_asyncio from httpx import ASGITransport, AsyncClient -@pytest.fixture +@pytest_asyncio.fixture async def api_client(tmp_path_factory): db_path = tmp_path_factory.mktemp("db") / "test.db" os.environ["DATABASE_URL"] = f"sqlite+aiosqlite:///{db_path}" - from elastisched.api import db as db_module - from elastisched.api import main as main_module + from elastisched_api import db as db_module + from elastisched_api import main as main_module importlib.reload(db_module) importlib.reload(main_module) @@ -50,7 +51,15 @@ async def test_create_and_get_blob(api_client): fetched = get_resp.json() assert fetched["name"] == payload["name"] - assert fetched["default_scheduled_timerange"]["start"] == payload["default_scheduled_timerange"]["start"] + def _coerce_utc(value: str) -> datetime: + parsed = datetime.fromisoformat(value) + if parsed.tzinfo is None: + return parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + assert _coerce_utc(fetched["default_scheduled_timerange"]["start"]) == _coerce_utc( + payload["default_scheduled_timerange"]["start"] + ) @pytest.mark.asyncio diff --git a/tests/test_blob.py b/tests/test_blob.py index 867eab4..a28d0f6 100644 --- a/tests/test_blob.py +++ b/tests/test_blob.py @@ -1,7 +1,7 @@ from datetime import datetime, timedelta, timezone -from elastisched.blob import Blob -from elastisched.timerange import TimeRange +from core.blob import Blob +from core.timerange import TimeRange def test_different_timezone_le_comparison_normalizes_timezone(): diff --git a/tests/test_cost_function.py b/tests/test_cost_function.py index 984fd25..f7a563c 100644 --- a/tests/test_cost_function.py +++ b/tests/test_cost_function.py @@ -1,143 +1,233 @@ from .constants import * import engine -import math - -def test_thursday_no_cost(): - # Given - tag = engine.Tag(WORK_TAG) - policy = engine.Policy(0, 0, 0) - tr_schedulable = engine.TimeRange(Day.THURSDAY * DAY + Hour.TWELVE_AM * HOUR, - Day.SATURDAY * DAY + Hour.ELEVEN_PM * HOUR) - tr_scheduled = engine.TimeRange(Day.THURSDAY * DAY + Hour.ELEVEN_PM * HOUR, - Day.THURSDAY * DAY + Hour.ELEVEN_PM * HOUR + 30 * MINUTE) +import pytest + + +def _make_job( + schedulable_low, + schedulable_high, + scheduled_low, + scheduled_high, + policy=None, + job_id="job", +): + policy = policy or engine.Policy(0, 0, 0) + tr_schedulable = engine.TimeRange(schedulable_low, schedulable_high) + tr_scheduled = engine.TimeRange(scheduled_low, scheduled_high) duration = tr_scheduled.getHigh() - tr_scheduled.getLow() - - job = engine.Job( + return engine.Job( duration, tr_schedulable, tr_scheduled, - "test_job", + job_id, policy, - set(), # dependencies - {tag}, # tags + set(), + set(), ) - - schedule = engine.Schedule([job]) - cost_function = engine.ScheduleCostFunction(schedule, GRANULARITY) - # Assert that - assert(cost_function.schedule_cost() < EPSILON) - -def test_busy_day_constant_cost(): - # Given - for day in range(5): - tag = engine.Tag(WORK_TAG) - policy = engine.Policy(0, 0, 0) - tr_schedulable = engine.TimeRange(day * DAY + Hour.TWELVE_AM * HOUR, - (day + 1) * DAY + Hour.ELEVEN_PM * HOUR) - tr_scheduled = engine.TimeRange(day * DAY + Hour.ELEVEN_PM * HOUR, - day * DAY + Hour.ELEVEN_PM * HOUR + 30 * MINUTE) - duration = tr_scheduled.getHigh() - tr_scheduled.getLow() - - job = engine.Job( - duration, - tr_schedulable, - tr_scheduled, - "test_job", - policy, - set(), # dependencies - {tag}, # tags - ) - - schedule = engine.Schedule([job]) - - cost_function = engine.ScheduleCostFunction(schedule, GRANULARITY) - - # Assert that - assert(abs(cost_function.busy_day_constant_cost(day) - 0.5) < EPSILON) - - -def test_next_saturday_cost(): - # Given - tag = engine.Tag(WORK_TAG) - policy = engine.Policy(0, 0, 0) - tr_schedulable = engine.TimeRange(WEEK + Day.SATURDAY * DAY + Hour.TWELVE_AM * HOUR, - WEEK + Day.SUNDAY * DAY + Hour.ELEVEN_PM * HOUR) - tr_scheduled = engine.TimeRange(WEEK + Day.SATURDAY * DAY + Hour.ELEVEN_PM * HOUR, - WEEK + Day.SATURDAY * DAY + Hour.ELEVEN_PM * HOUR + 30 * MINUTE) - duration = tr_scheduled.getHigh() - tr_scheduled.getLow() +""" +DEPRECATED COST FUNCTIONS TESTS - job = engine.Job( - duration, - tr_schedulable, - tr_scheduled, - "test_job", - policy, - set(), # dependencies - {tag}, # tags - ) - - schedule = engine.Schedule([job]) - - cost_function = engine.ScheduleCostFunction(schedule, GRANULARITY) +Note: currently the cost function only implements cost function primitives: - # Assert that - assert(abs(cost_function.schedule_cost() - 0.5) < EPSILON) +split cost +illegal schedule cost +overlap cost -def test_busy_afternoon_cost(): - # Given - day = Day.MONDAY +Everything else is handled by preference learner +""" - tag = engine.Tag(WORK_TAG) - policy = engine.Policy(0, 0, 0) - tr_schedulable = engine.TimeRange(day * DAY + Hour.TWELVE_AM * HOUR, - (day + 1) * DAY + Hour.ELEVEN_PM * HOUR) - tr_scheduled = engine.TimeRange(day * DAY + Hour.ELEVEN_PM * HOUR, - day * DAY + Hour.ELEVEN_PM * HOUR + 30 * MINUTE) - duration = tr_scheduled.getHigh() - tr_scheduled.getLow() - job = engine.Job( - duration, - tr_schedulable, - tr_scheduled, - "test_job", - policy, - set(), # dependencies - {tag}, # tags +def test_illegal_schedule_cost_outside_schedulable_range(): + job = _make_job( + schedulable_low=0, + schedulable_high=HOUR, + scheduled_low=2 * HOUR, + scheduled_high=3 * HOUR, ) - schedule = engine.Schedule([job]) - cost_function = engine.ScheduleCostFunction(schedule, GRANULARITY) - print(cost_function.busy_afternoon_exponential_cost(day.value)) - - # Assert that - assert(abs(cost_function.busy_afternoon_exponential_cost(day.value) - math.exp(EXP_DOWNFACTOR * tr_scheduled.length() / HOUR)) < EPSILON) - -def test_fri_cost(): - # Given - tag = engine.Tag(WORK_TAG) - policy = engine.Policy(0, 0, 0) - tr_schedulable = engine.TimeRange(Day.THURSDAY * DAY + Hour.TWELVE_AM * HOUR, - Day.SATURDAY * DAY + Hour.ELEVEN_PM * HOUR) - tr_scheduled = engine.TimeRange(Day.FRIDAY * DAY + 1 * MINUTE, - Day.FRIDAY * DAY + 30 * MINUTE) - duration = tr_scheduled.getHigh() - tr_scheduled.getLow() - job = engine.Job( - duration, - tr_schedulable, - tr_scheduled, - "test_job", - policy, - set(), # dependencies - {tag}, # tags + assert cost_function.schedule_cost() == pytest.approx(1e12, rel=1e-6) + + +def test_overlap_cost_counts_overlap_duration(): + overlappable_policy = engine.Policy(0, 0, 2) + job_a = _make_job( + schedulable_low=0, + schedulable_high=4 * HOUR, + scheduled_low=0, + scheduled_high=HOUR, + policy=overlappable_policy, + job_id="job_a", ) + job_b = _make_job( + schedulable_low=0, + schedulable_high=4 * HOUR, + scheduled_low=30 * MINUTE, + scheduled_high=90 * MINUTE, + policy=overlappable_policy, + job_id="job_b", + ) + schedule = engine.Schedule([job_a, job_b]) + cost_function = engine.ScheduleCostFunction(schedule, MINUTE) - schedule = engine.Schedule([job]) + assert cost_function.schedule_cost() == 30.0 - cost_function = engine.ScheduleCostFunction(schedule, GRANULARITY) - print(cost_function.busy_afternoon_exponential_cost(Day.FRIDAY.value)) - assert(cost_function.schedule_cost() < EPSILON) +def test_split_cost_counts_number_of_splits(): + job = _make_job( + schedulable_low=0, + schedulable_high=6 * HOUR, + scheduled_low=HOUR, + scheduled_high=2 * HOUR, + job_id="split_job", + ) + job.scheduledTimeRanges = [ + engine.TimeRange(HOUR, 2 * HOUR), + engine.TimeRange(3 * HOUR, 4 * HOUR), + ] + schedule = engine.Schedule([job]) + cost_function = engine.ScheduleCostFunction(schedule, MINUTE) + + assert cost_function.schedule_cost() == 10.0 +# def test_thursday_no_cost(): +# # Given +# tag = engine.Tag(WORK_TAG) +# policy = engine.Policy(0, 0, 0) +# tr_schedulable = engine.TimeRange(Day.THURSDAY * DAY + Hour.TWELVE_AM * HOUR, +# Day.SATURDAY * DAY + Hour.ELEVEN_PM * HOUR) +# tr_scheduled = engine.TimeRange(Day.THURSDAY * DAY + Hour.ELEVEN_PM * HOUR, +# Day.THURSDAY * DAY + Hour.ELEVEN_PM * HOUR + 30 * MINUTE) +# duration = tr_scheduled.getHigh() - tr_scheduled.getLow() + +# job = engine.Job( +# duration, +# tr_schedulable, +# tr_scheduled, +# "test_job", +# policy, +# set(), # dependencies +# {tag}, # tags +# ) + +# schedule = engine.Schedule([job]) + +# cost_function = engine.ScheduleCostFunction(schedule, GRANULARITY) + +# # Assert that +# assert(cost_function.schedule_cost() < EPSILON) + +# def test_busy_day_constant_cost(): +# # Given +# for day in range(5): +# tag = engine.Tag(WORK_TAG) +# policy = engine.Policy(0, 0, 0) +# tr_schedulable = engine.TimeRange(day * DAY + Hour.TWELVE_AM * HOUR, +# (day + 1) * DAY + Hour.ELEVEN_PM * HOUR) +# tr_scheduled = engine.TimeRange(day * DAY + Hour.ELEVEN_PM * HOUR, +# day * DAY + Hour.ELEVEN_PM * HOUR + 30 * MINUTE) +# duration = tr_scheduled.getHigh() - tr_scheduled.getLow() + +# job = engine.Job( +# duration, +# tr_schedulable, +# tr_scheduled, +# "test_job", +# policy, +# set(), # dependencies +# {tag}, # tags +# ) + +# schedule = engine.Schedule([job]) + +# cost_function = engine.ScheduleCostFunction(schedule, GRANULARITY) + +# # Assert that +# assert(abs(cost_function.busy_day_constant_cost(day) - 0.5) < EPSILON) + + +# def test_next_saturday_cost(): +# # Given +# tag = engine.Tag(WORK_TAG) +# policy = engine.Policy(0, 0, 0) +# tr_schedulable = engine.TimeRange(WEEK + Day.SATURDAY * DAY + Hour.TWELVE_AM * HOUR, +# WEEK + Day.SUNDAY * DAY + Hour.ELEVEN_PM * HOUR) +# tr_scheduled = engine.TimeRange(WEEK + Day.SATURDAY * DAY + Hour.ELEVEN_PM * HOUR, +# WEEK + Day.SATURDAY * DAY + Hour.ELEVEN_PM * HOUR + 30 * MINUTE) +# duration = tr_scheduled.getHigh() - tr_scheduled.getLow() + +# job = engine.Job( +# duration, +# tr_schedulable, +# tr_scheduled, +# "test_job", +# policy, +# set(), # dependencies +# {tag}, # tags +# ) + +# schedule = engine.Schedule([job]) + +# cost_function = engine.ScheduleCostFunction(schedule, GRANULARITY) + +# # Assert that +# assert(abs(cost_function.schedule_cost() - 0.5) < EPSILON) + +# def test_busy_afternoon_cost(): +# # Given +# day = Day.MONDAY + +# tag = engine.Tag(WORK_TAG) +# policy = engine.Policy(0, 0, 0) +# tr_schedulable = engine.TimeRange(day * DAY + Hour.TWELVE_AM * HOUR, +# (day + 1) * DAY + Hour.ELEVEN_PM * HOUR) +# tr_scheduled = engine.TimeRange(day * DAY + Hour.ELEVEN_PM * HOUR, +# day * DAY + Hour.ELEVEN_PM * HOUR + 30 * MINUTE) +# duration = tr_scheduled.getHigh() - tr_scheduled.getLow() + +# job = engine.Job( +# duration, +# tr_schedulable, +# tr_scheduled, +# "test_job", +# policy, +# set(), # dependencies +# {tag}, # tags +# ) + +# schedule = engine.Schedule([job]) + +# cost_function = engine.ScheduleCostFunction(schedule, GRANULARITY) +# print(cost_function.busy_afternoon_exponential_cost(day.value)) + +# # Assert that +# assert(abs(cost_function.busy_afternoon_exponential_cost(day.value) - math.exp(EXP_DOWNFACTOR * tr_scheduled.length() / HOUR)) < EPSILON) + +# def test_fri_cost(): +# # Given +# tag = engine.Tag(WORK_TAG) +# policy = engine.Policy(0, 0, 0) +# tr_schedulable = engine.TimeRange(Day.THURSDAY * DAY + Hour.TWELVE_AM * HOUR, +# Day.SATURDAY * DAY + Hour.ELEVEN_PM * HOUR) +# tr_scheduled = engine.TimeRange(Day.FRIDAY * DAY + 1 * MINUTE, +# Day.FRIDAY * DAY + 30 * MINUTE) +# duration = tr_scheduled.getHigh() - tr_scheduled.getLow() + +# job = engine.Job( +# duration, +# tr_schedulable, +# tr_scheduled, +# "test_job", +# policy, +# set(), # dependencies +# {tag}, # tags +# ) + +# schedule = engine.Schedule([job]) + +# cost_function = engine.ScheduleCostFunction(schedule, GRANULARITY) +# print(cost_function.busy_afternoon_exponential_cost(Day.FRIDAY.value)) + +# assert(cost_function.schedule_cost() < EPSILON) diff --git a/tests/test_engine.py b/tests/test_engine.py index 13537be..71b0404 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -3,74 +3,74 @@ from .constants import RANDOM_TEST_ITERATIONS import pytest -@pytest.mark.repeat(RANDOM_TEST_ITERATIONS) -def test_fri_sat_cost(): - # Given - tag = engine.Tag(WORK_TAG) - policy = engine.Policy(0, 0, 0) - tr_schedulable = engine.TimeRange(Day.THURSDAY * DAY + Hour.TWELVE_AM * HOUR, - Day.SATURDAY * DAY + Hour.ELEVEN_PM * HOUR) - tr_scheduled = engine.TimeRange(Day.FRIDAY * DAY, - Day.FRIDAY * DAY + 30 * MINUTE) - duration = tr_scheduled.getHigh() - tr_scheduled.getLow() - - job = engine.Job( - duration, - tr_schedulable, - tr_scheduled, - "test_job", - policy, - set(), # dependencies - {tag}, # tags - ) - - schedule, cost_history = engine.schedule_jobs([job], GRANULARITY, 1000.0, 1.0, 10000000000) - print(cost_history) - - # Assert that - for job in schedule.scheduledJobs: - curr_scheduled_tr = job.scheduledTimeRange - curr_schedulable_tr = job.schedulableTimeRange - assert(curr_scheduled_tr.getLow() <= Day.FRIDAY * DAY + AFTERNOON_START * HOUR) - assert(curr_schedulable_tr.contains(curr_scheduled_tr)) - - -@pytest.mark.repeat(RANDOM_TEST_ITERATIONS) -def test_friday_exponential_cost(): - # Given - tag = engine.Tag(WORK_TAG) - policy = engine.Policy(0, 0, 0) - tr_schedulable = engine.TimeRange(Day.FRIDAY * DAY, - Day.FRIDAY * DAY + Hour.ELEVEN_PM * HOUR) - tr_scheduled = engine.TimeRange(Day.FRIDAY * DAY + Hour.SEVEN_PM * HOUR, - Day.FRIDAY * DAY + Hour.SEVEN_PM * HOUR + 30 * MINUTE) - duration = tr_scheduled.getHigh() - tr_scheduled.getLow() - - - job = engine.Job( - duration, - tr_schedulable, - tr_scheduled, - "test_job", - policy, - set(), # dependencies - {tag}, # tags - ) - - schedule, cost_history = engine.schedule_jobs([job], GRANULARITY, 1000.0, 1.0, 100000) - print(cost_history) - - print(Day.FRIDAY * DAY + Hour.SEVEN_PM * HOUR) - # Assert that - for job in schedule.scheduledJobs: - curr_scheduled_tr = job.scheduledTimeRange - curr_schedulable_tr = job.schedulableTimeRange - - # Friday jobs are exponentially discounted - # Singleton friday jobs should be scheduled before the - # afternoon cutoff time - assert(not ((curr_scheduled_tr.getLow() // HOUR) % 24) > AFTERNOON_START) - assert(curr_schedulable_tr.contains(curr_scheduled_tr)) +# @pytest.mark.repeat(RANDOM_TEST_ITERATIONS) +# def test_fri_sat_cost(): +# # Given +# tag = engine.Tag(WORK_TAG) +# policy = engine.Policy(0, 0, 0) +# tr_schedulable = engine.TimeRange(Day.THURSDAY * DAY + Hour.TWELVE_AM * HOUR, +# Day.SATURDAY * DAY + Hour.ELEVEN_PM * HOUR) +# tr_scheduled = engine.TimeRange(Day.FRIDAY * DAY, +# Day.FRIDAY * DAY + 30 * MINUTE) +# duration = tr_scheduled.getHigh() - tr_scheduled.getLow() + +# job = engine.Job( +# duration, +# tr_schedulable, +# tr_scheduled, +# "test_job", +# policy, +# set(), # dependencies +# {tag}, # tags +# ) + +# schedule, cost_history = engine.schedule_jobs([job], GRANULARITY, 1000.0, 1.0, 10000000000) +# print(cost_history) + +# # Assert that +# for job in schedule.scheduledJobs: +# curr_scheduled_tr = job.scheduledTimeRange +# curr_schedulable_tr = job.schedulableTimeRange +# assert(curr_scheduled_tr.getLow() <= Day.FRIDAY * DAY + AFTERNOON_START * HOUR) +# assert(curr_schedulable_tr.contains(curr_scheduled_tr)) + + +# @pytest.mark.repeat(RANDOM_TEST_ITERATIONS) +# def test_friday_exponential_cost(): +# # Given +# tag = engine.Tag(WORK_TAG) +# policy = engine.Policy(0, 0, 0) +# tr_schedulable = engine.TimeRange(Day.FRIDAY * DAY, +# Day.FRIDAY * DAY + Hour.ELEVEN_PM * HOUR) +# tr_scheduled = engine.TimeRange(Day.FRIDAY * DAY + Hour.SEVEN_PM * HOUR, +# Day.FRIDAY * DAY + Hour.SEVEN_PM * HOUR + 30 * MINUTE) +# duration = tr_scheduled.getHigh() - tr_scheduled.getLow() + + +# job = engine.Job( +# duration, +# tr_schedulable, +# tr_scheduled, +# "test_job", +# policy, +# set(), # dependencies +# {tag}, # tags +# ) + +# schedule, cost_history = engine.schedule_jobs([job], GRANULARITY, 1000.0, 1.0, 100000) +# print(cost_history) + +# print(Day.FRIDAY * DAY + Hour.SEVEN_PM * HOUR) +# # Assert that +# for job in schedule.scheduledJobs: +# curr_scheduled_tr = job.scheduledTimeRange +# curr_schedulable_tr = job.schedulableTimeRange + +# # Friday jobs are exponentially discounted +# # Singleton friday jobs should be scheduled before the +# # afternoon cutoff time +# assert(not ((curr_scheduled_tr.getLow() // HOUR) % 24) > AFTERNOON_START) +# assert(curr_schedulable_tr.contains(curr_scheduled_tr)) @pytest.mark.repeat(RANDOM_TEST_ITERATIONS) @@ -102,3 +102,45 @@ def test_scheduler_invariance_cost(): curr_schedulable_tr = job.schedulableTimeRange assert(curr_schedulable_tr.contains(curr_scheduled_tr)) assert(curr_scheduled_tr.getLow() == tr_scheduled.getLow()) + + +def test_force_split_schedule(monkeypatch): + monkeypatch.setenv("ELASTISCHED_RNG_SEED", "4242") + split_policy = engine.Policy(1, HOUR, 1, True) + rigid_policy = engine.Policy(0, 0, 0) + + schedulable = engine.TimeRange(Day.MONDAY * DAY + Hour.NINE_AM * HOUR, + Day.MONDAY * DAY + Hour.TWELVE_PM * HOUR) + rigid_range = engine.TimeRange(Day.MONDAY * DAY + Hour.TEN_AM * HOUR, + Day.MONDAY * DAY + Hour.ELEVEN_AM * HOUR) + + split_job = engine.Job( + 2 * HOUR, + schedulable, + engine.TimeRange(Day.MONDAY * DAY + Hour.NINE_AM * HOUR, + Day.MONDAY * DAY + Hour.ELEVEN_AM * HOUR), + "split_job", + split_policy, + set(), + set(), + ) + + rigid_job = engine.Job( + HOUR, + rigid_range, + rigid_range, + "rigid_job", + rigid_policy, + set(), + set(), + ) + + schedule, _ = engine.schedule_jobs([split_job, rigid_job], HOUR, 1000.0, 0.01, 50000) + scheduled_split = next(job for job in schedule.scheduledJobs if job.id == "split_job") + ranges = list(scheduled_split.scheduledTimeRanges) + + assert len(ranges) == 2 + total_duration = sum(r.getHigh() - r.getLow() for r in ranges) + assert total_duration == split_job.duration + assert all((r.getHigh() - r.getLow()) >= HOUR for r in ranges) + assert all(r.getLow() % HOUR == 0 for r in ranges) diff --git a/tests/test_recurrence_date.py b/tests/test_recurrence_date.py index 0618727..ec0d079 100644 --- a/tests/test_recurrence_date.py +++ b/tests/test_recurrence_date.py @@ -1,9 +1,9 @@ from datetime import datetime, timedelta -from elastisched.blob import Blob -from elastisched.constants import DEFAULT_TZ -from elastisched.recurrence import DateBlobRecurrence -from elastisched.timerange import TimeRange +from core.blob import Blob +from core.constants import DEFAULT_TZ +from core.recurrence import DateBlobRecurrence +from core.timerange import TimeRange import pytest diff --git a/tests/test_recurrence_delta.py b/tests/test_recurrence_delta.py index 69a9616..98250a2 100644 --- a/tests/test_recurrence_delta.py +++ b/tests/test_recurrence_delta.py @@ -1,9 +1,10 @@ from datetime import datetime, timedelta +from zoneinfo import ZoneInfo -from elastisched.blob import Blob -from elastisched.constants import DEFAULT_TZ -from elastisched.recurrence import DeltaBlobRecurrence -from elastisched.timerange import TimeRange +from core.blob import Blob +from core.constants import DEFAULT_TZ +from core.recurrence import DeltaBlobRecurrence +from core.timerange import TimeRange def test_delta_next_occurrence_before_start(): @@ -104,3 +105,93 @@ def test_delta_all_occurrences_none_in_range(): # Assert that assert occurrences == [] + + +def test_delta_next_occurrence_dst_agnostic(monkeypatch): + tz = ZoneInfo("America/New_York") + import core.constants as constants + import core.timerange as timerange + + monkeypatch.setattr(constants, "DEFAULT_TZ", tz) + monkeypatch.setattr(timerange, "DEFAULT_TZ", tz) + + default_timerange = TimeRange( + start=datetime(2024, 3, 9, 9, tzinfo=tz), + end=datetime(2024, 3, 9, 10, tzinfo=tz), + ) + schedulable_timerange = TimeRange( + start=datetime(2024, 3, 9, 8, tzinfo=tz), + end=datetime(2024, 3, 9, 11, tzinfo=tz), + ) + blob = Blob(default_timerange, schedulable_timerange, tz=tz) + recurrence = DeltaBlobRecurrence(delta=timedelta(days=1), start_blob=blob) + + dt = datetime(2024, 3, 9, 12, tzinfo=tz) + next_occurrence = recurrence.next_occurrence(dt) + + assert next_occurrence.get_schedulable_timerange().start == datetime( + 2024, 3, 10, 8, tzinfo=tz + ) + assert next_occurrence.get_schedulable_timerange().end == datetime( + 2024, 3, 10, 11, tzinfo=tz + ) + + +def test_delta_next_occurrence_dst_fall_back_fold(monkeypatch): + tz = ZoneInfo("America/New_York") + import core.constants as constants + import core.timerange as timerange + + monkeypatch.setattr(constants, "DEFAULT_TZ", tz) + monkeypatch.setattr(timerange, "DEFAULT_TZ", tz) + + default_timerange = TimeRange( + start=datetime(2024, 11, 2, 1, 30, tzinfo=tz), + end=datetime(2024, 11, 2, 2, 30, tzinfo=tz), + ) + schedulable_timerange = TimeRange( + start=datetime(2024, 11, 2, 1, 30, tzinfo=tz), + end=datetime(2024, 11, 2, 3, 30, tzinfo=tz), + ) + blob = Blob(default_timerange, schedulable_timerange, tz=tz) + recurrence = DeltaBlobRecurrence(delta=timedelta(days=1), start_blob=blob) + + dt = datetime(2024, 11, 3, 1, 0, tzinfo=tz, fold=1) + next_occurrence = recurrence.next_occurrence(dt) + + assert next_occurrence.get_schedulable_timerange().start == datetime( + 2024, 11, 3, 1, 30, tzinfo=tz, fold=1 + ) + assert next_occurrence.get_schedulable_timerange().end == datetime( + 2024, 11, 3, 3, 30, tzinfo=tz, fold=1 + ) + + +def test_delta_next_occurrence_dst_spring_forward_duration(monkeypatch): + tz = ZoneInfo("America/New_York") + import core.constants as constants + import core.timerange as timerange + + monkeypatch.setattr(constants, "DEFAULT_TZ", tz) + monkeypatch.setattr(timerange, "DEFAULT_TZ", tz) + + default_timerange = TimeRange( + start=datetime(2024, 3, 9, 1, tzinfo=tz), + end=datetime(2024, 3, 9, 9, tzinfo=tz), + ) + schedulable_timerange = TimeRange( + start=datetime(2024, 3, 9, 1, tzinfo=tz), + end=datetime(2024, 3, 9, 9, tzinfo=tz), + ) + blob = Blob(default_timerange, schedulable_timerange, tz=tz) + recurrence = DeltaBlobRecurrence(delta=timedelta(days=1), start_blob=blob) + + dt = datetime(2024, 3, 9, 12, tzinfo=tz) + next_occurrence = recurrence.next_occurrence(dt) + + assert next_occurrence.get_schedulable_timerange().start == datetime( + 2024, 3, 10, 1, tzinfo=tz + ) + assert next_occurrence.get_schedulable_timerange().end == datetime( + 2024, 3, 10, 9, tzinfo=tz + ) diff --git a/tests/test_recurrence_multiple.py b/tests/test_recurrence_multiple.py new file mode 100644 index 0000000..4c2156a --- /dev/null +++ b/tests/test_recurrence_multiple.py @@ -0,0 +1,77 @@ +from datetime import datetime + +from core.blob import Blob +from core.recurrence import MultipleBlobOccurrence +from core.timerange import TimeRange + + +def _make_blob(start: datetime, end: datetime) -> Blob: + default_tr = TimeRange(start=start, end=end) + schedulable_tr = TimeRange(start=start, end=end) + return Blob( + default_scheduled_timerange=default_tr, + schedulable_timerange=schedulable_tr, + ) + + +def test_multiple_next_occurrence_picks_earliest_future(): + blob1 = _make_blob( + datetime(2024, 1, 1, 10, 0), + datetime(2024, 1, 1, 11, 0), + ) + blob2 = _make_blob( + datetime(2024, 1, 2, 10, 0), + datetime(2024, 1, 2, 11, 0), + ) + blob3 = _make_blob( + datetime(2024, 1, 3, 10, 0), + datetime(2024, 1, 3, 11, 0), + ) + + recurrence = MultipleBlobOccurrence(blobs=[blob3, blob1, blob2]) + next_occurrence = recurrence.next_occurrence(datetime(2023, 12, 31, 9, 0)) + + assert next_occurrence is not None + assert next_occurrence.get_schedulable_timerange().start == blob1.get_schedulable_timerange().start + + +def test_multiple_next_occurrence_skips_past_occurrences(): + blob1 = _make_blob( + datetime(2024, 1, 1, 10, 0), + datetime(2024, 1, 1, 11, 0), + ) + blob2 = _make_blob( + datetime(2024, 1, 2, 10, 0), + datetime(2024, 1, 2, 11, 0), + ) + + recurrence = MultipleBlobOccurrence(blobs=[blob1, blob2]) + next_occurrence = recurrence.next_occurrence(datetime(2024, 1, 1, 12, 0)) + + assert next_occurrence is not None + assert next_occurrence.get_schedulable_timerange().start == blob2.get_schedulable_timerange().start + + +def test_multiple_all_occurrences_in_range(): + blob1 = _make_blob( + datetime(2024, 1, 1, 10, 0), + datetime(2024, 1, 1, 11, 0), + ) + blob2 = _make_blob( + datetime(2024, 1, 2, 10, 0), + datetime(2024, 1, 2, 11, 0), + ) + blob3 = _make_blob( + datetime(2024, 1, 5, 10, 0), + datetime(2024, 1, 5, 11, 0), + ) + + recurrence = MultipleBlobOccurrence(blobs=[blob1, blob2, blob3]) + search_range = TimeRange( + start=datetime(2024, 1, 2, 0, 0), + end=datetime(2024, 1, 4, 0, 0), + ) + occurrences = recurrence.all_occurrences(search_range) + + assert len(occurrences) == 1 + assert occurrences[0].get_schedulable_timerange().start == blob2.get_schedulable_timerange().start diff --git a/tests/test_recurrence_single.py b/tests/test_recurrence_single.py index b59e999..1047291 100644 --- a/tests/test_recurrence_single.py +++ b/tests/test_recurrence_single.py @@ -1,9 +1,9 @@ from datetime import datetime, timedelta -from elastisched.blob import Blob -from elastisched.constants import DEFAULT_TZ -from elastisched.recurrence import SingleBlobOccurrence -from elastisched.timerange import TimeRange +from core.blob import Blob +from core.constants import DEFAULT_TZ +from core.recurrence import SingleBlobOccurrence +from core.timerange import TimeRange def test_schedulable_after_current_next_occurrence(): diff --git a/tests/test_recurrence_weekly.py b/tests/test_recurrence_weekly.py index b335171..077c326 100644 --- a/tests/test_recurrence_weekly.py +++ b/tests/test_recurrence_weekly.py @@ -1,9 +1,9 @@ from datetime import datetime -from elastisched.constants import DEFAULT_TZ -from elastisched.blob import Blob -from elastisched.recurrence import WeeklyBlobRecurrence -from elastisched.timerange import TimeRange +from core.constants import DEFAULT_TZ +from core.blob import Blob +from core.recurrence import WeeklyBlobRecurrence +from core.timerange import TimeRange import pytest diff --git a/tests/test_schedule_workflow.py b/tests/test_schedule_workflow.py index 6b8ce3c..43cdd80 100644 --- a/tests/test_schedule_workflow.py +++ b/tests/test_schedule_workflow.py @@ -16,9 +16,9 @@ async def api_client(tmp_path_factory): os.environ["DATABASE_URL"] = f"sqlite+aiosqlite:///{db_path}" os.environ["ELASTISCHED_PROJECT_TZ"] = "UTC" - from elastisched.api import db as db_module - from elastisched.api import main as main_module - from elastisched.api import models as models_module + from elastisched_api import db as db_module + from elastisched_api import main as main_module + from elastisched_api import models as models_module importlib.reload(db_module) importlib.reload(models_module) @@ -37,6 +37,7 @@ def _next_weekday_date(current: datetime, target_weekday: int) -> datetime.date: @pytest.mark.asyncio +@pytest.mark.skip(reason="Deprecated: non-primitive cost function behavior is handled by preference learning.") async def test_frontend_to_scheduler_friday_afternoon_cost(api_client): user_tz = ZoneInfo("America/Los_Angeles") project_tz = timezone.utc @@ -146,6 +147,7 @@ def to_project_iso(value: datetime) -> str: @pytest.mark.asyncio +@pytest.mark.skip(reason="Deprecated: non-primitive cost preferences handled by learner.") async def test_single_occurrence_scheduled_same_day_before_afternoon( api_client, monkeypatch ): @@ -154,7 +156,7 @@ async def test_single_occurrence_scheduled_same_day_before_afternoon( fixed_now = datetime(2026, 1, 1, 12, 0, tzinfo=timezone.utc) - import elastisched.api.schedule_router as schedule_router + import elastisched_api.schedule_router as schedule_router class FrozenDateTime(datetime): @classmethod diff --git a/tests/test_timerange.py b/tests/test_timerange.py index 22bc0ae..836c9b0 100644 --- a/tests/test_timerange.py +++ b/tests/test_timerange.py @@ -1,7 +1,7 @@ from datetime import datetime, timedelta, timezone -from elastisched.constants import DEFAULT_TZ -from elastisched.timerange import TimeRange +from core.constants import DEFAULT_TZ +from core.timerange import TimeRange def test_default_timerange_uses_default_timezone(): diff --git a/tests/test_utils_round_datetime_future_bias.py b/tests/test_utils_round_datetime_future_bias.py index ef6dc84..eee8604 100644 --- a/tests/test_utils_round_datetime_future_bias.py +++ b/tests/test_utils_round_datetime_future_bias.py @@ -2,7 +2,7 @@ import pytest -from src.elastisched.utils import round_datetime_future_bias +from core.utils import round_datetime_future_bias def test_round_to_future_minute_future_bias():