Merge remote-tracking branch 'origin/main' into refactor/organize-django-apps

# Conflicts:
#	smoothschedule/smoothschedule/scheduling/schedule/serializers.py
This commit is contained in:
poduck
2025-12-07 21:12:09 -05:00
13 changed files with 931 additions and 67 deletions

116
CLAUDE.md
View File

@@ -61,6 +61,122 @@ docker compose -f docker-compose.local.yml exec django python manage.py <command
| `frontend/src/api/client.ts` | Axios API client |
| `frontend/src/types.ts` | TypeScript interfaces |
| `frontend/src/i18n/locales/en.json` | Translations |
| `frontend/src/utils/dateUtils.ts` | Date formatting utilities |
## Timezone Architecture (CRITICAL)
All date/time handling follows this architecture to ensure consistency across timezones.
### Core Principles
1. **Database**: All times stored in UTC
2. **API Communication**: Always use UTC (both directions)
3. **API Responses**: Include `business_timezone` field
4. **Frontend Display**: Convert UTC based on `business_timezone`
- If `business_timezone` is set → display in that timezone
- If `business_timezone` is null/blank → display in user's local timezone
### Data Flow
```
FRONTEND (User in Eastern Time selects "Dec 8, 2:00 PM")
Convert to UTC: "2024-12-08T19:00:00Z"
Send to API (always UTC)
DATABASE (stores UTC)
API RESPONSE:
{
"start_time": "2024-12-08T19:00:00Z", // Always UTC
"business_timezone": "America/Denver" // IANA timezone (or null for local)
}
FRONTEND CONVERTS:
- If business_timezone set: UTC → Mountain Time → "Dec 8, 12:00 PM MST"
- If business_timezone null: UTC → User local → "Dec 8, 2:00 PM EST"
```
### Frontend Helper Functions
Located in `frontend/src/utils/dateUtils.ts`:
```typescript
import {
toUTC,
fromUTC,
formatForDisplay,
formatDateForDisplay,
getDisplayTimezone,
} from '../utils/dateUtils';
// SENDING TO API - Always convert to UTC
const apiPayload = {
start_time: toUTC(selectedDateTime), // "2024-12-08T19:00:00Z"
};
// RECEIVING FROM API - Convert for display
const displayTime = formatForDisplay(
response.start_time, // UTC from API
response.business_timezone // "America/Denver" or null
);
// Result: "Dec 8, 2024 12:00 PM" (in business or local timezone)
// DATE-ONLY fields (time blocks)
const displayDate = formatDateForDisplay(
response.start_date,
response.business_timezone
);
```
### API Response Requirements
All endpoints returning date/time data MUST include:
```python
# In serializers or views
{
"start_time": "2024-12-08T19:00:00Z",
"business_timezone": business.timezone, # "America/Denver" or None
}
```
### Backend Serializer Mixin
Use `TimezoneSerializerMixin` from `core/mixins.py` to automatically add the timezone field:
```python
from core.mixins import TimezoneSerializerMixin
class EventSerializer(TimezoneSerializerMixin, serializers.ModelSerializer):
class Meta:
model = Event
fields = [
'id', 'start_time', 'end_time',
# ... other fields ...
'business_timezone', # Provided by mixin
]
read_only_fields = ['business_timezone']
```
The mixin automatically retrieves the timezone from the tenant context.
- Returns the IANA timezone string if set (e.g., "America/Denver")
- Returns `null` if not set (frontend uses user's local timezone)
### Common Mistakes to Avoid
```typescript
// BAD - Uses browser local time, not UTC
date.toISOString().split('T')[0]
// BAD - Doesn't respect business timezone setting
new Date(utcString).toLocaleString()
// GOOD - Use helper functions
toUTC(date) // For API requests
formatForDisplay(utcString, businessTimezone) // For displaying
```
## Django App Organization (Domain-Based)

View File

@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react';
import axios from '../api/client';
import { X, Calendar, Clock, RotateCw, Zap } from 'lucide-react';
import { formatLocalDate } from '../utils/dateUtils';
interface ScheduledTask {
id: string;
@@ -79,7 +80,7 @@ const EditTaskModal: React.FC<EditTaskModalProps> = ({ task, isOpen, onClose, on
setScheduleMode('onetime');
if (task.run_at) {
const date = new Date(task.run_at);
setRunAtDate(date.toISOString().split('T')[0]);
setRunAtDate(formatLocalDate(date));
setRunAtTime(date.toTimeString().slice(0, 5));
}
} else if (task.schedule_type === 'INTERVAL') {

View File

@@ -1,7 +1,7 @@
import React, { useState, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { Bell, Check, CheckCheck, Trash2, X, Ticket, Calendar, MessageSquare } from 'lucide-react';
import { Bell, Check, CheckCheck, Trash2, X, Ticket, Calendar, MessageSquare, Clock } from 'lucide-react';
import {
useNotifications,
useUnreadNotificationCount,
@@ -56,6 +56,14 @@ const NotificationDropdown: React.FC<NotificationDropdownProps> = ({ variant = '
}
}
// Handle time-off request notifications - navigate to time blocks page
// Includes both new requests and modified requests that need re-approval
if (notification.data?.type === 'time_off_request' || notification.data?.type === 'time_off_request_modified') {
navigate('/time-blocks');
setIsOpen(false);
return;
}
// Navigate to target if available
if (notification.target_url) {
navigate(notification.target_url);
@@ -71,8 +79,13 @@ const NotificationDropdown: React.FC<NotificationDropdownProps> = ({ variant = '
clearAllMutation.mutate();
};
const getNotificationIcon = (targetType: string | null) => {
switch (targetType) {
const getNotificationIcon = (notification: Notification) => {
// Check for time-off request type in data (new or modified)
if (notification.data?.type === 'time_off_request' || notification.data?.type === 'time_off_request_modified') {
return <Clock size={16} className="text-amber-500" />;
}
switch (notification.target_type) {
case 'ticket':
return <Ticket size={16} className="text-blue-500" />;
case 'event':
@@ -171,7 +184,7 @@ const NotificationDropdown: React.FC<NotificationDropdownProps> = ({ variant = '
>
<div className="flex items-start gap-3">
<div className="mt-0.5">
{getNotificationIcon(notification.target_type)}
{getNotificationIcon(notification)}
</div>
<div className="flex-1 min-w-0">
<p className={`text-sm ${!notification.read ? 'font-medium' : ''} text-gray-900 dark:text-white`}>

View File

@@ -41,6 +41,7 @@ import {
Resource,
TimeBlockListItem,
} from '../../types';
import { formatLocalDate } from '../../utils/dateUtils';
// Preset block types
const PRESETS = [
@@ -418,8 +419,8 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
if (recurrenceType === 'NONE') {
if (selectedDates.length > 0) {
const sorted = [...selectedDates].sort((a, b) => a.getTime() - b.getTime());
data.start_date = sorted[0].toISOString().split('T')[0];
data.end_date = sorted[sorted.length - 1].toISOString().split('T')[0];
data.start_date = formatLocalDate(sorted[0]);
data.end_date = formatLocalDate(sorted[sorted.length - 1]);
}
} else if (recurrenceType === 'WEEKLY') {
data.recurrence_pattern = { days_of_week: daysOfWeek };

View File

@@ -14,6 +14,7 @@ import { useTranslation } from 'react-i18next';
import { ChevronLeft, ChevronRight, CalendarDays, X } from 'lucide-react';
import { BlockedDate, TimeBlockListItem } from '../../types';
import { useBlockedDates, useTimeBlock } from '../../hooks/useTimeBlocks';
import { formatLocalDate } from '../../utils/dateUtils';
interface YearlyBlockCalendarProps {
resourceId?: string;
@@ -134,7 +135,7 @@ const YearlyBlockCalendar: React.FC<YearlyBlockCalendarProps> = ({
return <div key={`empty-${i}`} className="aspect-square" />;
}
const dateKey = day.toISOString().split('T')[0];
const dateKey = formatLocalDate(day);
const blocks = blockedDateMap.get(dateKey) || [];
const hasBlocks = blocks.length > 0;
const isToday = new Date().toDateString() === day.toDateString();

View File

@@ -14,6 +14,7 @@ import Portal from '../components/Portal';
import EventAutomations from '../components/EventAutomations';
import TimeBlockCalendarOverlay from '../components/time-blocks/TimeBlockCalendarOverlay';
import { getOverQuotaResourceIds } from '../utils/quotaUtils';
import { formatLocalDate } from '../utils/dateUtils';
// Time settings
const START_HOUR = 0; // Midnight
@@ -87,8 +88,8 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
// Fetch blocked dates for the calendar overlay
const blockedDatesParams = useMemo(() => ({
start_date: dateRange.startDate.toISOString().split('T')[0],
end_date: dateRange.endDate.toISOString().split('T')[0],
start_date: formatLocalDate(dateRange.startDate),
end_date: formatLocalDate(dateRange.endDate),
include_business: true,
}), [dateRange]);
const { data: blockedDates = [] } = useBlockedDates(blockedDatesParams);

View File

@@ -33,28 +33,134 @@ const GeneralSettings: React.FC = () => {
setFormState(prev => ({ ...prev, [name]: value }));
};
// Common timezones grouped by region
const commonTimezones = [
{ value: 'America/New_York', label: 'Eastern Time (New York)' },
{ value: 'America/Chicago', label: 'Central Time (Chicago)' },
{ value: 'America/Denver', label: 'Mountain Time (Denver)' },
{ value: 'America/Los_Angeles', label: 'Pacific Time (Los Angeles)' },
{ value: 'America/Anchorage', label: 'Alaska Time' },
{ value: 'Pacific/Honolulu', label: 'Hawaii Time' },
{ value: 'America/Phoenix', label: 'Arizona (no DST)' },
{ value: 'America/Toronto', label: 'Eastern Time (Toronto)' },
{ value: 'America/Vancouver', label: 'Pacific Time (Vancouver)' },
{ value: 'Europe/London', label: 'London (GMT/BST)' },
{ value: 'Europe/Paris', label: 'Central European Time' },
{ value: 'Europe/Berlin', label: 'Berlin' },
{ value: 'Asia/Tokyo', label: 'Japan Time' },
{ value: 'Asia/Shanghai', label: 'China Time' },
{ value: 'Asia/Singapore', label: 'Singapore Time' },
{ value: 'Asia/Dubai', label: 'Dubai (GST)' },
{ value: 'Australia/Sydney', label: 'Sydney (AEST)' },
{ value: 'Australia/Melbourne', label: 'Melbourne (AEST)' },
{ value: 'Pacific/Auckland', label: 'New Zealand Time' },
{ value: 'UTC', label: 'UTC' },
// IANA timezones grouped by region
const timezoneGroups = [
{
label: 'United States',
timezones: [
'America/New_York',
'America/Chicago',
'America/Denver',
'America/Los_Angeles',
'America/Anchorage',
'Pacific/Honolulu',
'America/Phoenix',
'America/Detroit',
'America/Indiana/Indianapolis',
'America/Boise',
],
},
{
label: 'Canada',
timezones: [
'America/Toronto',
'America/Vancouver',
'America/Edmonton',
'America/Winnipeg',
'America/Halifax',
'America/St_Johns',
],
},
{
label: 'Mexico & Central America',
timezones: [
'America/Mexico_City',
'America/Tijuana',
'America/Cancun',
'America/Guatemala',
'America/Costa_Rica',
'America/Panama',
],
},
{
label: 'South America',
timezones: [
'America/Sao_Paulo',
'America/Argentina/Buenos_Aires',
'America/Santiago',
'America/Bogota',
'America/Lima',
'America/Caracas',
],
},
{
label: 'Europe',
timezones: [
'Europe/London',
'Europe/Dublin',
'Europe/Paris',
'Europe/Berlin',
'Europe/Madrid',
'Europe/Rome',
'Europe/Amsterdam',
'Europe/Brussels',
'Europe/Vienna',
'Europe/Zurich',
'Europe/Stockholm',
'Europe/Oslo',
'Europe/Copenhagen',
'Europe/Helsinki',
'Europe/Athens',
'Europe/Bucharest',
'Europe/Warsaw',
'Europe/Prague',
'Europe/Moscow',
'Europe/Istanbul',
],
},
{
label: 'Asia',
timezones: [
'Asia/Tokyo',
'Asia/Seoul',
'Asia/Shanghai',
'Asia/Hong_Kong',
'Asia/Taipei',
'Asia/Singapore',
'Asia/Kuala_Lumpur',
'Asia/Bangkok',
'Asia/Ho_Chi_Minh',
'Asia/Jakarta',
'Asia/Manila',
'Asia/Kolkata',
'Asia/Mumbai',
'Asia/Dubai',
'Asia/Riyadh',
'Asia/Jerusalem',
'Asia/Karachi',
'Asia/Dhaka',
],
},
{
label: 'Australia & Pacific',
timezones: [
'Australia/Sydney',
'Australia/Melbourne',
'Australia/Brisbane',
'Australia/Perth',
'Australia/Adelaide',
'Australia/Darwin',
'Pacific/Auckland',
'Pacific/Fiji',
'Pacific/Guam',
],
},
{
label: 'Africa',
timezones: [
'Africa/Johannesburg',
'Africa/Cairo',
'Africa/Lagos',
'Africa/Nairobi',
'Africa/Casablanca',
],
},
{
label: 'Other',
timezones: [
'UTC',
],
},
];
const handleSave = async () => {
@@ -146,10 +252,14 @@ const GeneralSettings: React.FC = () => {
onChange={handleChange}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-brand-500"
>
{commonTimezones.map(tz => (
<option key={tz.value} value={tz.value}>
{tz.label}
</option>
{timezoneGroups.map(group => (
<optgroup key={group.label} label={group.label}>
{group.timezones.map(tz => (
<option key={tz} value={tz}>
{tz}
</option>
))}
</optgroup>
))}
</select>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">

View File

@@ -0,0 +1,393 @@
/**
* Date/Time Utility Functions
*
* TIMEZONE ARCHITECTURE:
* - Database: All times stored in UTC
* - API Communication: Always UTC (both directions)
* - Frontend Display: Convert based on business_timezone setting
* - If business_timezone is set: Display in that timezone
* - If business_timezone is blank/null: Display in user's local timezone
*
* See CLAUDE.md for full architecture documentation.
*/
// ============================================================================
// SENDING TO API - Convert to UTC
// ============================================================================
/**
* Convert a local Date to UTC ISO string for API requests.
* Use this when sending datetime values to the API.
*
* @example
* const payload = { start_time: toUTC(selectedDate) };
* // Returns: "2024-12-08T19:00:00.000Z"
*/
export const toUTC = (date: Date): string => {
return date.toISOString();
};
/**
* Convert a date and time (in display timezone) to UTC ISO string.
* Use when the user selects a time that should be interpreted in a specific timezone.
*
* @param date - The date portion
* @param time - Time string "HH:MM"
* @param timezone - IANA timezone the user is selecting in (business or local)
*
* @example
* // User in Eastern selects 2pm, but display mode is "business" (Mountain)
* // This means they selected 2pm Mountain time
* toUTCFromTimezone(date, "14:00", "America/Denver")
*/
export const toUTCFromTimezone = (
date: Date,
time: string,
timezone: string
): string => {
const [hours, minutes] = time.split(':').map(Number);
const dateStr = formatLocalDate(date);
// Create a date string that we'll parse in the target timezone
const dateTimeStr = `${dateStr}T${time}:00`;
// Use Intl to get the UTC offset for this timezone at this date/time
const targetDate = new Date(dateTimeStr);
const utcDate = convertTimezoneToUTC(targetDate, timezone);
return utcDate.toISOString();
};
/**
* Convert a Date object from a specific timezone to UTC.
* The input Date's time values are interpreted as being in the given timezone.
*/
export const convertTimezoneToUTC = (date: Date, timezone: string): Date => {
// Get the date/time components as they appear (treating as target timezone)
const year = date.getFullYear();
const month = date.getMonth();
const day = date.getDate();
const hours = date.getHours();
const minutes = date.getMinutes();
const seconds = date.getSeconds();
// Create a formatter for the target timezone
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
// Find the UTC time that displays as our target time in the target timezone
// We do this by creating a UTC date and adjusting based on offset
const tempDate = new Date(Date.UTC(year, month, day, hours, minutes, seconds));
// Get what this UTC time displays as in the target timezone
const parts = formatter.formatToParts(tempDate);
const getPart = (type: string) => parseInt(parts.find(p => p.type === type)?.value || '0');
const displayedHour = getPart('hour');
const displayedMinute = getPart('minute');
const displayedDay = getPart('day');
// Calculate the offset in minutes
const displayedMinutes = displayedDay * 24 * 60 + displayedHour * 60 + displayedMinute;
const targetMinutes = day * 24 * 60 + hours * 60 + minutes;
const offsetMinutes = displayedMinutes - targetMinutes;
// Adjust the UTC time by the offset
return new Date(tempDate.getTime() - offsetMinutes * 60 * 1000);
};
// ============================================================================
// RECEIVING FROM API - Convert from UTC for Display
// ============================================================================
/**
* Convert a UTC datetime string from API to a Date object in the display timezone.
*
* @param utcString - ISO string from API (always UTC)
* @param businessTimezone - IANA timezone of the business (null = use local)
*/
export const fromUTC = (
utcString: string,
businessTimezone?: string | null
): Date => {
const utcDate = new Date(utcString);
const targetTimezone = getDisplayTimezone(businessTimezone);
return convertUTCToTimezone(utcDate, targetTimezone);
};
/**
* Convert a UTC Date to display in a specific timezone.
* Returns a Date object with values adjusted for the target timezone.
*/
export const convertUTCToTimezone = (utcDate: Date, timezone: string): Date => {
// Format the UTC date in the target timezone
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
const parts = formatter.formatToParts(utcDate);
const getPart = (type: string) => parseInt(parts.find(p => p.type === type)?.value || '0');
// Create a new Date with the timezone-adjusted values
// Note: This Date object's internal UTC value won't match, but the displayed values will be correct
return new Date(
getPart('year'),
getPart('month') - 1,
getPart('day'),
getPart('hour'),
getPart('minute'),
getPart('second')
);
};
/**
* Get the timezone to use for display.
* If businessTimezone is set, use it. Otherwise use user's local timezone.
*/
export const getDisplayTimezone = (businessTimezone?: string | null): string => {
if (businessTimezone) {
return businessTimezone;
}
// No business timezone set - use browser's local timezone
return Intl.DateTimeFormat().resolvedOptions().timeZone;
};
/**
* Get the user's local timezone (browser timezone).
*/
export const getUserTimezone = (): string => {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
};
// ============================================================================
// FORMATTING FOR DISPLAY
// ============================================================================
/**
* Format a UTC datetime string for display, respecting timezone settings.
*
* @param utcString - ISO string from API
* @param businessTimezone - IANA timezone of the business (null = use local)
* @param options - Intl.DateTimeFormat options for customizing output
*
* @example
* formatForDisplay("2024-12-08T19:00:00Z", "America/Denver")
* // Returns: "Dec 8, 2024, 12:00 PM" (Mountain Time)
*
* formatForDisplay("2024-12-08T19:00:00Z", null)
* // Returns time in user's local timezone
*/
export const formatForDisplay = (
utcString: string,
businessTimezone?: string | null,
options?: Intl.DateTimeFormatOptions
): string => {
const utcDate = new Date(utcString);
const timezone = getDisplayTimezone(businessTimezone);
const defaultOptions: Intl.DateTimeFormatOptions = {
timeZone: timezone,
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true,
...options,
};
return utcDate.toLocaleString('en-US', defaultOptions);
};
/**
* Format just the time portion for display.
*
* @example
* formatTimeForDisplay("2024-12-08T19:00:00Z", "America/Denver")
* // Returns: "12:00 PM"
*/
export const formatTimeForDisplay = (
utcString: string,
businessTimezone?: string | null
): string => {
const utcDate = new Date(utcString);
const timezone = getDisplayTimezone(businessTimezone);
return utcDate.toLocaleString('en-US', {
timeZone: timezone,
hour: 'numeric',
minute: '2-digit',
hour12: true,
});
};
/**
* Format just the date portion for display.
*
* @example
* formatDateForDisplay("2024-12-08T19:00:00Z", "America/Denver")
* // Returns: "Dec 8, 2024"
*/
export const formatDateForDisplay = (
utcString: string,
businessTimezone?: string | null,
options?: Intl.DateTimeFormatOptions
): string => {
const utcDate = new Date(utcString);
const timezone = getDisplayTimezone(businessTimezone);
const defaultOptions: Intl.DateTimeFormatOptions = {
timeZone: timezone,
year: 'numeric',
month: 'short',
day: 'numeric',
...options,
};
return utcDate.toLocaleDateString('en-US', defaultOptions);
};
/**
* Format a datetime for datetime-local input, in the display timezone.
* Returns: "YYYY-MM-DDTHH:MM"
*/
export const formatForDateTimeInput = (
utcString: string,
businessTimezone?: string | null
): string => {
const displayDate = fromUTC(utcString, businessTimezone);
return formatLocalDateTime(displayDate);
};
// ============================================================================
// DATE-ONLY HELPERS (for fields like time block start_date/end_date)
// ============================================================================
/**
* Format a Date object as YYYY-MM-DD string.
* Uses the Date's local values (not UTC).
*
* For date-only fields, use this when you have a Date object
* representing a calendar date selection.
*/
export const formatLocalDate = (date: Date): string => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
/**
* Parse a YYYY-MM-DD string as a local date (at midnight local time).
*/
export const parseLocalDate = (dateString: string): Date => {
const [year, month, day] = dateString.split('-').map(Number);
return new Date(year, month - 1, day);
};
/**
* Format a Date as YYYY-MM-DDTHH:MM for datetime-local inputs.
*/
export const formatLocalDateTime = (date: Date): string => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day}T${hours}:${minutes}`;
};
/**
* Get today's date as YYYY-MM-DD in a specific timezone.
* Useful for determining "today" in the business timezone.
*/
export const getTodayInTimezone = (timezone: string): string => {
const now = new Date();
const formatter = new Intl.DateTimeFormat('en-CA', {
timeZone: timezone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
return formatter.format(now); // Returns YYYY-MM-DD format
};
/**
* Check if a date string (YYYY-MM-DD) is today in the given timezone.
*/
export const isToday = (dateString: string, timezone: string): boolean => {
return dateString === getTodayInTimezone(timezone);
};
// ============================================================================
// UTILITY HELPERS
// ============================================================================
/**
* Check if two dates are the same day (ignoring time).
*/
export const isSameDay = (date1: Date, date2: Date): boolean => {
return (
date1.getFullYear() === date2.getFullYear() &&
date1.getMonth() === date2.getMonth() &&
date1.getDate() === date2.getDate()
);
};
/**
* Get the start of a day (midnight local time).
*/
export const startOfDay = (date: Date): Date => {
const result = new Date(date);
result.setHours(0, 0, 0, 0);
return result;
};
/**
* Get the end of a day (23:59:59.999 local time).
*/
export const endOfDay = (date: Date): Date => {
const result = new Date(date);
result.setHours(23, 59, 59, 999);
return result;
};
/**
* Get timezone abbreviation for display (e.g., "MST", "EST").
*/
export const getTimezoneAbbreviation = (
timezone: string,
date: Date = new Date()
): string => {
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
timeZoneName: 'short',
});
const parts = formatter.formatToParts(date);
return parts.find(p => p.type === 'timeZoneName')?.value || timezone;
};
/**
* Format timezone for display (e.g., "Mountain Time (MST)").
*/
export const formatTimezoneDisplay = (timezone: string): string => {
const abbr = getTimezoneAbbreviation(timezone);
const cityName = timezone.split('/').pop()?.replace(/_/g, ' ') || timezone;
return `${cityName} (${abbr})`;
};

View File

@@ -12,6 +12,7 @@ from smoothschedule.communication.mobile.models import (
EmployeeLocationUpdate,
FieldCallLog,
)
from core.mixins import TimezoneSerializerMixin
class ServiceSummarySerializer(serializers.ModelSerializer):
@@ -49,11 +50,12 @@ class CustomerInfoSerializer(serializers.Serializer):
return None
class JobListSerializer(serializers.ModelSerializer):
class JobListSerializer(TimezoneSerializerMixin, serializers.ModelSerializer):
"""
Serializer for job list (today's and upcoming jobs).
Optimized for quick loading on mobile.
Includes business_timezone for proper time display.
"""
service_name = serializers.SerializerMethodField()
customer_name = serializers.SerializerMethodField()
@@ -76,6 +78,7 @@ class JobListSerializer(serializers.ModelSerializer):
'address',
'duration_minutes',
'allowed_transitions',
'business_timezone',
]
def get_service_name(self, obj):
@@ -142,11 +145,12 @@ class JobListSerializer(serializers.ModelSerializer):
return None
class JobDetailSerializer(serializers.ModelSerializer):
class JobDetailSerializer(TimezoneSerializerMixin, serializers.ModelSerializer):
"""
Full job details for the job detail screen.
Includes all information needed to work on the job.
Includes business_timezone for proper time display.
"""
service = ServiceSummarySerializer(read_only=True)
customer = serializers.SerializerMethodField()
@@ -184,6 +188,7 @@ class JobDetailSerializer(serializers.ModelSerializer):
'created_at',
'updated_at',
'can_edit_schedule',
'business_timezone',
]
def get_customer(self, obj):

View File

@@ -1,10 +1,11 @@
"""
Core Mixins for DRF ViewSets
Core Mixins for DRF ViewSets and Serializers
Reusable mixins to reduce code duplication across ViewSets.
Reusable mixins to reduce code duplication across ViewSets and Serializers.
"""
from rest_framework.permissions import BasePermission
from rest_framework.exceptions import PermissionDenied
from rest_framework import serializers
# ==============================================================================
@@ -544,3 +545,93 @@ class TenantRequiredAPIView(TenantAPIView):
if not self.tenant:
return self.tenant_required_response()
return super().dispatch(request, *args, **kwargs)
# ==============================================================================
# Serializer Mixins
# ==============================================================================
class TimezoneSerializerMixin:
"""
Mixin that adds timezone context field to serializer responses.
TIMEZONE ARCHITECTURE:
- Database: All times stored in UTC
- API Communication: Always UTC (both directions)
- Frontend Display: Convert based on business_timezone
- If business_timezone is set: Display in that timezone
- If business_timezone is null/blank: Display in user's local timezone
This mixin adds one field to every response:
- business_timezone: IANA timezone string (e.g., "America/Denver") or null
The frontend uses this to convert UTC times for display.
Usage:
class EventSerializer(TimezoneSerializerMixin, serializers.ModelSerializer):
class Meta:
model = Event
fields = [..., 'business_timezone']
Or for nested/list serializers, ensure the view passes tenant context:
class EventViewSet(ModelViewSet):
def get_serializer_context(self):
context = super().get_serializer_context()
context['tenant'] = getattr(self.request, 'tenant', None)
return context
"""
business_timezone = serializers.SerializerMethodField()
def get_business_timezone(self, obj):
"""
Get the business timezone from tenant.
Returns the IANA timezone string, or None to use user's local timezone.
"""
tenant = self._get_tenant_from_context()
if tenant:
# Return the timezone if set, None otherwise (frontend will use local)
return getattr(tenant, 'timezone', None) or None
return None
def _get_tenant_from_context(self):
"""Get tenant from serializer context or database connection."""
# Try from context first (passed by view)
tenant = self.context.get('tenant')
if tenant:
return tenant
# Try from request in context
request = self.context.get('request')
if request:
tenant = getattr(request, 'tenant', None)
if tenant:
return tenant
# Fallback to connection.tenant (django-tenants)
try:
from django.db import connection
if hasattr(connection, 'tenant'):
return connection.tenant
except Exception:
pass
return None
class TimezoneContextMixin:
"""
ViewSet mixin that passes tenant context to serializers.
Use this with serializers that use TimezoneSerializerMixin.
Usage:
class EventViewSet(TimezoneContextMixin, ModelViewSet):
serializer_class = EventSerializer
"""
def get_serializer_context(self):
"""Add tenant to serializer context."""
context = super().get_serializer_context()
context['tenant'] = getattr(self.request, 'tenant', None)
return context

View File

@@ -264,13 +264,17 @@ class User(AbstractUser):
def can_self_approve_time_off(self):
"""
Check if user can self-approve time off requests.
Owners and managers can always self-approve.
Owners can always self-approve.
Managers can self-approve by default but can be denied.
Staff need explicit permission.
"""
# Owners and managers can always self-approve
if self.role in [self.Role.TENANT_OWNER, self.Role.TENANT_MANAGER]:
# Owners can always self-approve
if self.role == self.Role.TENANT_OWNER:
return True
# Staff can self-approve if granted permission
# Managers can self-approve by default, but can be denied
if self.role == self.Role.TENANT_MANAGER:
return self.permissions.get('can_self_approve_time_off', True)
# Staff can self-approve if granted permission (default: False)
if self.role == self.Role.TENANT_STAFF:
return self.permissions.get('can_self_approve_time_off', False)
return False

View File

@@ -7,6 +7,7 @@ from django.core.exceptions import ValidationError as DjangoValidationError
from .models import Resource, Event, Participant, Service, ResourceType, ScheduledTask, TaskExecutionLog, PluginTemplate, PluginInstallation, EventPlugin, GlobalEventPlugin, EmailTemplate, Holiday, TimeBlock
from .services import AvailabilityService
from smoothschedule.identity.users.models import User
from smoothschedule.identity.core.mixins import TimezoneSerializerMixin
class ResourceTypeSerializer(serializers.ModelSerializer):
@@ -285,12 +286,16 @@ class ParticipantSerializer(serializers.ModelSerializer):
return str(obj.content_object) if obj.content_object else None
class EventSerializer(serializers.ModelSerializer):
class EventSerializer(TimezoneSerializerMixin, serializers.ModelSerializer):
"""
Serializer for Event model with availability validation.
CRITICAL: Validates resource availability before saving via AvailabilityService.
Includes timezone context via TimezoneSerializerMixin:
- timezone_display_mode: "business" or "viewer"
- business_timezone: IANA timezone string
Status mapping (frontend -> backend):
- PENDING -> SCHEDULED
- CONFIRMED -> SCHEDULED
@@ -368,9 +373,12 @@ class EventSerializer(serializers.ModelSerializer):
'service', 'deposit_amount', 'deposit_transaction_id', 'final_price',
'final_charge_transaction_id', 'is_variable_pricing', 'remaining_balance', 'overpaid_amount',
'created_at', 'updated_at', 'created_by',
# Timezone context (from TimezoneSerializerMixin)
'business_timezone',
]
read_only_fields = ['created_at', 'updated_at', 'created_by', 'deposit_transaction_id',
'final_charge_transaction_id', 'is_variable_pricing', 'remaining_balance', 'overpaid_amount']
'final_charge_transaction_id', 'is_variable_pricing', 'remaining_balance', 'overpaid_amount',
'business_timezone']
def get_duration_minutes(self, obj):
return int(obj.duration.total_seconds() / 60)
@@ -1160,8 +1168,12 @@ class HolidayListSerializer(serializers.ModelSerializer):
fields = ['code', 'name', 'country']
class TimeBlockSerializer(serializers.ModelSerializer):
"""Full serializer for TimeBlock CRUD operations"""
class TimeBlockSerializer(TimezoneSerializerMixin, serializers.ModelSerializer):
"""Full serializer for TimeBlock CRUD operations.
Includes timezone context via TimezoneSerializerMixin:
- business_timezone: IANA timezone string (null = use user's local)
"""
resource_name = serializers.CharField(source='resource.name', read_only=True, allow_null=True)
created_by_name = serializers.SerializerMethodField()
reviewed_by_name = serializers.SerializerMethodField()
@@ -1183,8 +1195,11 @@ class TimeBlockSerializer(serializers.ModelSerializer):
'reviewed_at', 'review_notes',
'created_by', 'created_by_name',
'conflict_count', 'created_at', 'updated_at',
# Timezone context (from TimezoneSerializerMixin)
'business_timezone',
]
read_only_fields = ['created_by', 'created_at', 'updated_at', 'reviewed_by', 'reviewed_at']
read_only_fields = ['created_by', 'created_at', 'updated_at', 'reviewed_by', 'reviewed_at',
'business_timezone']
def get_created_by_name(self, obj):
if obj.created_by:
@@ -1426,8 +1441,12 @@ class TimeBlockSerializer(serializers.ModelSerializer):
return super().create(validated_data)
class TimeBlockListSerializer(serializers.ModelSerializer):
"""Serializer for time block lists - includes fields needed for editing"""
class TimeBlockListSerializer(TimezoneSerializerMixin, serializers.ModelSerializer):
"""Serializer for time block lists - includes fields needed for editing.
Includes timezone context via TimezoneSerializerMixin:
- business_timezone: IANA timezone string (null = use user's local)
"""
resource_name = serializers.CharField(source='resource.name', read_only=True, allow_null=True)
created_by_name = serializers.SerializerMethodField()
reviewed_by_name = serializers.SerializerMethodField()
@@ -1444,6 +1463,8 @@ class TimeBlockListSerializer(serializers.ModelSerializer):
'is_active', 'approval_status', 'reviewed_by', 'reviewed_by_name',
'reviewed_at', 'review_notes',
'created_by', 'created_by_name', 'created_at',
# Timezone context (from TimezoneSerializerMixin)
'business_timezone',
]
def get_created_by_name(self, obj):

View File

@@ -533,6 +533,70 @@ def emit_status_change(event, old_status, new_status, changed_by, tenant, skip_n
# Custom signal for time-off request notifications
time_off_request_submitted = Signal()
# Fields that trigger re-approval when changed on an approved block
TIME_BLOCK_APPROVAL_FIELDS = [
'title', 'start_date', 'end_date', 'all_day', 'start_time', 'end_time',
'recurrence_type', 'recurrence_pattern', 'recurrence_start', 'recurrence_end',
]
@receiver(pre_save, sender='schedule.TimeBlock')
def track_time_block_changes(sender, instance, **kwargs):
"""
Track changes to TimeBlock before save and reset approval if needed.
When a staff member edits an already-approved time block:
1. Tracks which fields changed
2. If approval-sensitive fields changed, resets status to PENDING
3. Marks the instance so post_save knows to send notifications
This ensures that any edit to an approved time-off request triggers
a new approval workflow.
"""
instance._needs_re_approval_notification = False
if instance.pk:
try:
from .models import TimeBlock
old_instance = TimeBlock.objects.get(pk=instance.pk)
instance._old_approval_status = old_instance.approval_status
instance._was_approved = old_instance.approval_status == TimeBlock.ApprovalStatus.APPROVED
# Track which approval-sensitive fields changed
instance._changed_fields = []
for field in TIME_BLOCK_APPROVAL_FIELDS:
old_value = getattr(old_instance, field)
new_value = getattr(instance, field)
if old_value != new_value:
instance._changed_fields.append(field)
# If this was an approved block and significant fields changed,
# reset to PENDING status (treated as a new request)
if instance._was_approved and instance._changed_fields:
# Only reset if the user editing is not self-approving
# (owners/managers edits stay approved)
created_by = instance.created_by
if created_by and not created_by.can_self_approve_time_off():
logger.info(
f"TimeBlock {instance.id} was approved but modified "
f"(changed: {instance._changed_fields}). "
f"Resetting to PENDING for re-approval."
)
instance.approval_status = TimeBlock.ApprovalStatus.PENDING
instance.reviewed_by = None
instance.reviewed_at = None
instance.review_notes = ''
instance._needs_re_approval_notification = True
except sender.DoesNotExist:
instance._old_approval_status = None
instance._was_approved = False
instance._changed_fields = []
else:
instance._old_approval_status = None
instance._was_approved = False
instance._changed_fields = []
def is_notifications_available():
"""Check if the notifications app is installed and migrated."""
@@ -568,16 +632,20 @@ def create_notification_safe(recipient, actor, verb, action_object=None, target=
@receiver(post_save, sender='schedule.TimeBlock')
def notify_managers_on_pending_time_off(sender, instance, created, **kwargs):
"""
When a TimeBlock is created with PENDING approval status,
notify all managers and owners in the business.
"""
if not created:
return
When a TimeBlock is created with PENDING approval status or when
an approved TimeBlock is modified, notify all managers and owners.
Handles two scenarios:
1. New time-off request created with PENDING status
2. Approved time-off request modified (reset to PENDING)
"""
from .models import TimeBlock
# Only notify for pending requests (staff time-off that needs approval)
if instance.approval_status != TimeBlock.ApprovalStatus.PENDING:
# Check if this is a new pending request OR a modified approved request
is_new_pending = created and instance.approval_status == TimeBlock.ApprovalStatus.PENDING
needs_re_approval = getattr(instance, '_needs_re_approval_notification', False)
if not is_new_pending and not needs_re_approval:
return
# Only for resource-level blocks (staff time-off)
@@ -589,10 +657,23 @@ def notify_managers_on_pending_time_off(sender, instance, created, **kwargs):
if not requester:
return
logger.info(
f"Time-off request submitted by {requester.get_full_name() or requester.email} "
f"for resource '{instance.resource.name}'"
)
# Determine notification type for logging and data
if needs_re_approval:
changed_fields = getattr(instance, '_changed_fields', [])
verb = 'modified time off request'
notification_type = 'time_off_request_modified'
logger.info(
f"Time-off request modified by {requester.get_full_name() or requester.email} "
f"for resource '{instance.resource.name}' (changed: {changed_fields}). "
f"Request returned to pending status for re-approval."
)
else:
verb = 'requested time off'
notification_type = 'time_off_request'
logger.info(
f"Time-off request submitted by {requester.get_full_name() or requester.email} "
f"for resource '{instance.resource.name}'"
)
# Find all managers and owners to notify
from smoothschedule.identity.users.models import User
@@ -607,7 +688,7 @@ def notify_managers_on_pending_time_off(sender, instance, created, **kwargs):
notification = create_notification_safe(
recipient=reviewer,
actor=requester,
verb='requested time off',
verb=verb,
action_object=instance,
target=instance.resource,
data={
@@ -615,7 +696,9 @@ def notify_managers_on_pending_time_off(sender, instance, created, **kwargs):
'title': instance.title,
'resource_name': instance.resource.name if instance.resource else None,
'requester_name': requester.get_full_name() or requester.email,
'type': 'time_off_request',
'type': notification_type,
'is_modification': needs_re_approval,
'changed_fields': getattr(instance, '_changed_fields', []) if needs_re_approval else [],
}
)
if notification:
@@ -627,17 +710,23 @@ def notify_managers_on_pending_time_off(sender, instance, created, **kwargs):
time_block=instance,
requester=requester,
reviewers=list(reviewers),
is_modification=needs_re_approval,
changed_fields=getattr(instance, '_changed_fields', []) if needs_re_approval else [],
)
@receiver(time_off_request_submitted)
def send_time_off_email_notifications(sender, time_block, requester, reviewers, **kwargs):
"""
Send email notifications to reviewers when a time-off request is submitted.
Send email notifications to reviewers when a time-off request is submitted or modified.
"""
from django.core.mail import send_mail
from django.conf import settings
# Check if this is a modification of an existing approved request
is_modification = kwargs.get('is_modification', False)
changed_fields = kwargs.get('changed_fields', [])
# Get the resource name for the email
resource_name = time_block.resource.name if time_block.resource else 'Unknown'
requester_name = requester.get_full_name() or requester.email
@@ -653,9 +742,27 @@ def send_time_off_email_notifications(sender, time_block, requester, reviewers,
else:
date_desc = f"Recurring ({time_block.get_recurrence_type_display()})"
subject = f"Time-Off Request: {requester_name} - {time_block.title or 'Time Off'}"
# Different subject and message for new vs modified requests
if is_modification:
subject = f"Modified Time-Off Request: {requester_name} - {time_block.title or 'Time Off'}"
changed_fields_str = ', '.join(changed_fields) if changed_fields else 'unspecified fields'
message = f"""
A previously approved time-off request has been modified and requires re-approval.
message = f"""
Requester: {requester_name}
Resource: {resource_name}
Title: {time_block.title or 'Time Off'}
Date(s): {date_desc}
Description: {time_block.description or 'No description provided'}
Modified fields: {changed_fields_str}
The request has been returned to pending status and needs your review.
Please log in to review this request.
"""
else:
subject = f"Time-Off Request: {requester_name} - {time_block.title or 'Time Off'}"
message = f"""
A new time-off request has been submitted and needs your review.
Requester: {requester_name}