File size: 7,639 Bytes
3b9a6b5
e3a1efe
 
 
 
 
 
3b9a6b5
 
 
 
 
 
918bdb4
3b9a6b5
 
 
 
 
 
 
 
 
918bdb4
3b9a6b5
 
918bdb4
3b9a6b5
918bdb4
3b9a6b5
 
e3a1efe
ede5a9e
e3a1efe
 
 
ede5a9e
 
e3a1efe
 
 
 
 
 
 
ede5a9e
e3a1efe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
918bdb4
3b9a6b5
918bdb4
3b9a6b5
 
e3a1efe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ede5a9e
 
e3a1efe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ede5a9e
 
 
 
 
e3a1efe
ede5a9e
e3a1efe
 
 
ede5a9e
 
 
 
 
e3a1efe
ede5a9e
e3a1efe
 
ede5a9e
e3a1efe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ede5a9e
 
e3a1efe
 
 
 
ede5a9e
 
 
 
 
e3a1efe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ede5a9e
e3a1efe
 
ede5a9e
 
e3a1efe
 
 
 
ede5a9e
 
 
 
 
 
 
e3a1efe
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
from icalendar import Calendar
from datetime import datetime, date, timezone, timedelta
from typing import Optional, Tuple, List, Dict, Any
from constraint_solvers.timetable.working_hours import (
    SLOTS_PER_WORKING_DAY,
    MORNING_SLOTS,
)


def extract_ical_entries(file_bytes):
    try:
        cal = Calendar.from_ical(file_bytes)
        entries = []

        for component in cal.walk():
            if component.name == "VEVENT":
                summary = str(component.get("summary", ""))
                dtstart = component.get("dtstart", "")
                dtend = component.get("dtend", "")

                def to_iso(val):
                    if hasattr(val, "dt"):
                        dt = val.dt

                        if hasattr(dt, "isoformat"):
                            return dt.isoformat()

                        return str(dt)

                    return str(val)

                def to_datetime(val):
                    """Convert icalendar datetime to Python datetime object, keeping original timezone."""
                    if hasattr(val, "dt"):
                        dt = val.dt
                        if isinstance(dt, datetime):
                            # Keep timezone-aware datetimes as-is, or return naive ones unchanged
                            return dt
                        elif isinstance(dt, date):
                            # Convert date to datetime at 9 AM (naive)
                            return datetime.combine(
                                dt, datetime.min.time().replace(hour=9)
                            )
                    return None

                # Parse datetime objects for slot calculation (keeping original timezone)
                start_datetime = to_datetime(dtstart)
                end_datetime = to_datetime(dtend)

                entry = {
                    "summary": summary,
                    "dtstart": to_iso(dtstart),
                    "dtend": to_iso(dtend),
                }

                # Add datetime objects for slot calculation
                if start_datetime:
                    entry["start_datetime"] = start_datetime
                if end_datetime:
                    entry["end_datetime"] = end_datetime

                entries.append(entry)

        return entries, None

    except Exception as e:
        return None, str(e)


def get_earliest_calendar_date(
    calendar_entries: List[Dict[str, Any]]
) -> Optional[date]:
    """
    Find the earliest date from calendar entries to use as base_date for scheduling.

    Args:
        calendar_entries: List of calendar entry dictionaries

    Returns:
        The earliest date found, or None if no valid dates found
    """
    earliest_date = None

    for entry in calendar_entries:
        start_datetime = entry.get("start_datetime")
        if start_datetime and isinstance(start_datetime, datetime):
            entry_date = start_datetime.date()
            if earliest_date is None or entry_date < earliest_date:
                earliest_date = entry_date

    return earliest_date


def validate_calendar_working_hours(
    calendar_entries: List[Dict[str, Any]]
) -> Tuple[bool, str]:
    """
    Validate that all calendar entries fall within standard working hours (9:00-18:00) and don't span lunch break (13:00-14:00).

    Each event is validated in its original timezone context.

    Args:
        calendar_entries: List of calendar entry dictionaries

    Returns:
        Tuple of (is_valid, error_message)
    """
    if not calendar_entries:
        return True, ""

    violations = []

    for entry in calendar_entries:
        summary = entry.get("summary", "Unknown Event")
        start_datetime = entry.get("start_datetime")
        end_datetime = entry.get("end_datetime")

        if start_datetime and isinstance(start_datetime, datetime):
            # Use the hour in the event's original timezone
            event_start_hour = start_datetime.hour
            event_start_minute = start_datetime.minute

            if event_start_hour < 9:
                violations.append(
                    f"'{summary}' starts at {event_start_hour:02d}:{event_start_minute:02d} (before 9:00)"
                )

        if end_datetime and isinstance(end_datetime, datetime):
            # Use the hour in the event's original timezone
            event_end_hour = end_datetime.hour
            event_end_minute = end_datetime.minute

            if event_end_hour > 18 or (event_end_hour == 18 and event_end_minute > 0):
                violations.append(
                    f"'{summary}' ends at {event_end_hour:02d}:{event_end_minute:02d} (after 18:00)"
                )

        # Check for lunch break spanning (13:00-14:00) in original timezone
        if (
            start_datetime
            and end_datetime
            and isinstance(start_datetime, datetime)
            and isinstance(end_datetime, datetime)
        ):
            start_hour_min = start_datetime.hour + start_datetime.minute / 60.0
            end_hour_min = end_datetime.hour + end_datetime.minute / 60.0

            # Check if task spans across lunch break (13:00-14:00)
            if start_hour_min < 14.0 and end_hour_min > 13.0:
                violations.append(
                    f"'{summary}' ({start_datetime.hour:02d}:{start_datetime.minute:02d}-{end_datetime.hour:02d}:{end_datetime.minute:02d}) spans lunch break (13:00-14:00)"
                )

    if violations:
        error_msg = "Calendar entries violate working constraints:\n" + "\n".join(
            violations
        )
        return False, error_msg

    return True, ""


def datetime_to_slot(dt: datetime, base_date: date) -> int:
    """
    Convert a datetime to a 30-minute slot index within working days.

    Args:
        dt: The datetime to convert (timezone-aware or naive)
        base_date: The base date (slot 0 = base_date at 9:00 AM)

    Returns:
        The slot index (each slot = 30 minutes within working hours)
    """
    # Convert timezone-aware datetime to naive for slot calculation
    if dt.tzinfo is not None:
        # Convert to local system timezone, then make naive
        dt = dt.astimezone().replace(tzinfo=None)

    # Calculate which working day this datetime falls on
    days_from_base = (dt.date() - base_date).days

    # Calculate time within the working day (minutes from 9:00 AM)
    minutes_from_9am = (dt.hour - 9) * 60 + dt.minute

    # Convert to slot within the day (each slot = 30 minutes)
    slot_within_day = round(minutes_from_9am / 30)

    # Calculate total slot index
    total_slot = days_from_base * SLOTS_PER_WORKING_DAY + slot_within_day

    # Ensure non-negative slot
    return max(0, total_slot)


def calculate_duration_slots(start_dt: datetime, end_dt: datetime) -> int:
    """
    Calculate duration in 30-minute slots between two datetimes.

    Args:
        start_dt: Start datetime (timezone-aware or naive)
        end_dt: End datetime (timezone-aware or naive)

    Returns:
        Duration in 30-minute slots (minimum 1 slot)
    """
    # Convert timezone-aware datetimes to naive for calculation
    if start_dt.tzinfo is not None:
        start_dt = start_dt.astimezone().replace(tzinfo=None)
    if end_dt.tzinfo is not None:
        end_dt = end_dt.astimezone().replace(tzinfo=None)

    # Calculate difference in minutes
    time_diff = end_dt - start_dt
    total_minutes = time_diff.total_seconds() / 60

    # Convert to 30-minute slots, rounding up to ensure task duration is preserved
    duration_slots = max(1, round(total_minutes / 30))

    return duration_slots