feat: Add timezone architecture for consistent date/time handling
- Create dateUtils.ts with helpers for UTC conversion and timezone display - Add TimezoneSerializerMixin to include business_timezone in API responses - Update GeneralSettings timezone dropdown with IANA identifiers - Apply timezone mixin to Event, TimeBlock, and field mobile serializers - Document timezone architecture in CLAUDE.md All times stored in UTC, converted for display based on business timezone. If business_timezone is null, uses user's local timezone. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
116
CLAUDE.md
116
CLAUDE.md
@@ -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/api/client.ts` | Axios API client |
|
||||||
| `frontend/src/types.ts` | TypeScript interfaces |
|
| `frontend/src/types.ts` | TypeScript interfaces |
|
||||||
| `frontend/src/i18n/locales/en.json` | Translations |
|
| `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
|
||||||
|
```
|
||||||
|
|
||||||
## Key Django Apps
|
## Key Django Apps
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import axios from '../api/client';
|
import axios from '../api/client';
|
||||||
import { X, Calendar, Clock, RotateCw, Zap } from 'lucide-react';
|
import { X, Calendar, Clock, RotateCw, Zap } from 'lucide-react';
|
||||||
|
import { formatLocalDate } from '../utils/dateUtils';
|
||||||
|
|
||||||
interface ScheduledTask {
|
interface ScheduledTask {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -79,7 +80,7 @@ const EditTaskModal: React.FC<EditTaskModalProps> = ({ task, isOpen, onClose, on
|
|||||||
setScheduleMode('onetime');
|
setScheduleMode('onetime');
|
||||||
if (task.run_at) {
|
if (task.run_at) {
|
||||||
const date = new Date(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));
|
setRunAtTime(date.toTimeString().slice(0, 5));
|
||||||
}
|
}
|
||||||
} else if (task.schedule_type === 'INTERVAL') {
|
} else if (task.schedule_type === 'INTERVAL') {
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
Resource,
|
Resource,
|
||||||
TimeBlockListItem,
|
TimeBlockListItem,
|
||||||
} from '../../types';
|
} from '../../types';
|
||||||
|
import { formatLocalDate } from '../../utils/dateUtils';
|
||||||
|
|
||||||
// Preset block types
|
// Preset block types
|
||||||
const PRESETS = [
|
const PRESETS = [
|
||||||
@@ -418,8 +419,8 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
|||||||
if (recurrenceType === 'NONE') {
|
if (recurrenceType === 'NONE') {
|
||||||
if (selectedDates.length > 0) {
|
if (selectedDates.length > 0) {
|
||||||
const sorted = [...selectedDates].sort((a, b) => a.getTime() - b.getTime());
|
const sorted = [...selectedDates].sort((a, b) => a.getTime() - b.getTime());
|
||||||
data.start_date = sorted[0].toISOString().split('T')[0];
|
data.start_date = formatLocalDate(sorted[0]);
|
||||||
data.end_date = sorted[sorted.length - 1].toISOString().split('T')[0];
|
data.end_date = formatLocalDate(sorted[sorted.length - 1]);
|
||||||
}
|
}
|
||||||
} else if (recurrenceType === 'WEEKLY') {
|
} else if (recurrenceType === 'WEEKLY') {
|
||||||
data.recurrence_pattern = { days_of_week: daysOfWeek };
|
data.recurrence_pattern = { days_of_week: daysOfWeek };
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { ChevronLeft, ChevronRight, CalendarDays, X } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, CalendarDays, X } from 'lucide-react';
|
||||||
import { BlockedDate, TimeBlockListItem } from '../../types';
|
import { BlockedDate, TimeBlockListItem } from '../../types';
|
||||||
import { useBlockedDates, useTimeBlock } from '../../hooks/useTimeBlocks';
|
import { useBlockedDates, useTimeBlock } from '../../hooks/useTimeBlocks';
|
||||||
|
import { formatLocalDate } from '../../utils/dateUtils';
|
||||||
|
|
||||||
interface YearlyBlockCalendarProps {
|
interface YearlyBlockCalendarProps {
|
||||||
resourceId?: string;
|
resourceId?: string;
|
||||||
@@ -134,7 +135,7 @@ const YearlyBlockCalendar: React.FC<YearlyBlockCalendarProps> = ({
|
|||||||
return <div key={`empty-${i}`} className="aspect-square" />;
|
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 blocks = blockedDateMap.get(dateKey) || [];
|
||||||
const hasBlocks = blocks.length > 0;
|
const hasBlocks = blocks.length > 0;
|
||||||
const isToday = new Date().toDateString() === day.toDateString();
|
const isToday = new Date().toDateString() === day.toDateString();
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import Portal from '../components/Portal';
|
|||||||
import EventAutomations from '../components/EventAutomations';
|
import EventAutomations from '../components/EventAutomations';
|
||||||
import TimeBlockCalendarOverlay from '../components/time-blocks/TimeBlockCalendarOverlay';
|
import TimeBlockCalendarOverlay from '../components/time-blocks/TimeBlockCalendarOverlay';
|
||||||
import { getOverQuotaResourceIds } from '../utils/quotaUtils';
|
import { getOverQuotaResourceIds } from '../utils/quotaUtils';
|
||||||
|
import { formatLocalDate } from '../utils/dateUtils';
|
||||||
|
|
||||||
// Time settings
|
// Time settings
|
||||||
const START_HOUR = 0; // Midnight
|
const START_HOUR = 0; // Midnight
|
||||||
@@ -87,8 +88,8 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
|
|||||||
|
|
||||||
// Fetch blocked dates for the calendar overlay
|
// Fetch blocked dates for the calendar overlay
|
||||||
const blockedDatesParams = useMemo(() => ({
|
const blockedDatesParams = useMemo(() => ({
|
||||||
start_date: dateRange.startDate.toISOString().split('T')[0],
|
start_date: formatLocalDate(dateRange.startDate),
|
||||||
end_date: dateRange.endDate.toISOString().split('T')[0],
|
end_date: formatLocalDate(dateRange.endDate),
|
||||||
include_business: true,
|
include_business: true,
|
||||||
}), [dateRange]);
|
}), [dateRange]);
|
||||||
const { data: blockedDates = [] } = useBlockedDates(blockedDatesParams);
|
const { data: blockedDates = [] } = useBlockedDates(blockedDatesParams);
|
||||||
|
|||||||
@@ -33,28 +33,134 @@ const GeneralSettings: React.FC = () => {
|
|||||||
setFormState(prev => ({ ...prev, [name]: value }));
|
setFormState(prev => ({ ...prev, [name]: value }));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Common timezones grouped by region
|
// IANA timezones grouped by region
|
||||||
const commonTimezones = [
|
const timezoneGroups = [
|
||||||
{ value: 'America/New_York', label: 'Eastern Time (New York)' },
|
{
|
||||||
{ value: 'America/Chicago', label: 'Central Time (Chicago)' },
|
label: 'United States',
|
||||||
{ value: 'America/Denver', label: 'Mountain Time (Denver)' },
|
timezones: [
|
||||||
{ value: 'America/Los_Angeles', label: 'Pacific Time (Los Angeles)' },
|
'America/New_York',
|
||||||
{ value: 'America/Anchorage', label: 'Alaska Time' },
|
'America/Chicago',
|
||||||
{ value: 'Pacific/Honolulu', label: 'Hawaii Time' },
|
'America/Denver',
|
||||||
{ value: 'America/Phoenix', label: 'Arizona (no DST)' },
|
'America/Los_Angeles',
|
||||||
{ value: 'America/Toronto', label: 'Eastern Time (Toronto)' },
|
'America/Anchorage',
|
||||||
{ value: 'America/Vancouver', label: 'Pacific Time (Vancouver)' },
|
'Pacific/Honolulu',
|
||||||
{ value: 'Europe/London', label: 'London (GMT/BST)' },
|
'America/Phoenix',
|
||||||
{ value: 'Europe/Paris', label: 'Central European Time' },
|
'America/Detroit',
|
||||||
{ value: 'Europe/Berlin', label: 'Berlin' },
|
'America/Indiana/Indianapolis',
|
||||||
{ value: 'Asia/Tokyo', label: 'Japan Time' },
|
'America/Boise',
|
||||||
{ value: 'Asia/Shanghai', label: 'China Time' },
|
],
|
||||||
{ value: 'Asia/Singapore', label: 'Singapore Time' },
|
},
|
||||||
{ value: 'Asia/Dubai', label: 'Dubai (GST)' },
|
{
|
||||||
{ value: 'Australia/Sydney', label: 'Sydney (AEST)' },
|
label: 'Canada',
|
||||||
{ value: 'Australia/Melbourne', label: 'Melbourne (AEST)' },
|
timezones: [
|
||||||
{ value: 'Pacific/Auckland', label: 'New Zealand Time' },
|
'America/Toronto',
|
||||||
{ value: 'UTC', label: 'UTC' },
|
'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 () => {
|
const handleSave = async () => {
|
||||||
@@ -146,11 +252,15 @@ const GeneralSettings: React.FC = () => {
|
|||||||
onChange={handleChange}
|
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"
|
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 => (
|
{timezoneGroups.map(group => (
|
||||||
<option key={tz.value} value={tz.value}>
|
<optgroup key={group.label} label={group.label}>
|
||||||
{tz.label}
|
{group.timezones.map(tz => (
|
||||||
|
<option key={tz} value={tz}>
|
||||||
|
{tz}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
|
</optgroup>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
{t('settings.timezone.businessTimezoneHint', 'The timezone where your business operates.')}
|
{t('settings.timezone.businessTimezoneHint', 'The timezone where your business operates.')}
|
||||||
|
|||||||
393
frontend/src/utils/dateUtils.ts
Normal file
393
frontend/src/utils/dateUtils.ts
Normal 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})`;
|
||||||
|
};
|
||||||
@@ -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.permissions import BasePermission
|
||||||
from rest_framework.exceptions import PermissionDenied
|
from rest_framework.exceptions import PermissionDenied
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
@@ -544,3 +545,93 @@ class TenantRequiredAPIView(TenantAPIView):
|
|||||||
if not self.tenant:
|
if not self.tenant:
|
||||||
return self.tenant_required_response()
|
return self.tenant_required_response()
|
||||||
return super().dispatch(request, *args, **kwargs)
|
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
|
||||||
|
|||||||
@@ -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 .models import Resource, Event, Participant, Service, ResourceType, ScheduledTask, TaskExecutionLog, PluginTemplate, PluginInstallation, EventPlugin, GlobalEventPlugin, EmailTemplate, Holiday, TimeBlock
|
||||||
from .services import AvailabilityService
|
from .services import AvailabilityService
|
||||||
from smoothschedule.users.models import User
|
from smoothschedule.users.models import User
|
||||||
|
from core.mixins import TimezoneSerializerMixin
|
||||||
|
|
||||||
|
|
||||||
class ResourceTypeSerializer(serializers.ModelSerializer):
|
class ResourceTypeSerializer(serializers.ModelSerializer):
|
||||||
@@ -285,12 +286,16 @@ class ParticipantSerializer(serializers.ModelSerializer):
|
|||||||
return str(obj.content_object) if obj.content_object else None
|
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.
|
Serializer for Event model with availability validation.
|
||||||
|
|
||||||
CRITICAL: Validates resource availability before saving via AvailabilityService.
|
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):
|
Status mapping (frontend -> backend):
|
||||||
- PENDING -> SCHEDULED
|
- PENDING -> SCHEDULED
|
||||||
- CONFIRMED -> SCHEDULED
|
- CONFIRMED -> SCHEDULED
|
||||||
@@ -368,9 +373,12 @@ class EventSerializer(serializers.ModelSerializer):
|
|||||||
'service', 'deposit_amount', 'deposit_transaction_id', 'final_price',
|
'service', 'deposit_amount', 'deposit_transaction_id', 'final_price',
|
||||||
'final_charge_transaction_id', 'is_variable_pricing', 'remaining_balance', 'overpaid_amount',
|
'final_charge_transaction_id', 'is_variable_pricing', 'remaining_balance', 'overpaid_amount',
|
||||||
'created_at', 'updated_at', 'created_by',
|
'created_at', 'updated_at', 'created_by',
|
||||||
|
# Timezone context (from TimezoneSerializerMixin)
|
||||||
|
'business_timezone',
|
||||||
]
|
]
|
||||||
read_only_fields = ['created_at', 'updated_at', 'created_by', 'deposit_transaction_id',
|
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):
|
def get_duration_minutes(self, obj):
|
||||||
return int(obj.duration.total_seconds() / 60)
|
return int(obj.duration.total_seconds() / 60)
|
||||||
@@ -1160,8 +1168,12 @@ class HolidayListSerializer(serializers.ModelSerializer):
|
|||||||
fields = ['code', 'name', 'country']
|
fields = ['code', 'name', 'country']
|
||||||
|
|
||||||
|
|
||||||
class TimeBlockSerializer(serializers.ModelSerializer):
|
class TimeBlockSerializer(TimezoneSerializerMixin, serializers.ModelSerializer):
|
||||||
"""Full serializer for TimeBlock CRUD operations"""
|
"""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)
|
resource_name = serializers.CharField(source='resource.name', read_only=True, allow_null=True)
|
||||||
created_by_name = serializers.SerializerMethodField()
|
created_by_name = serializers.SerializerMethodField()
|
||||||
reviewed_by_name = serializers.SerializerMethodField()
|
reviewed_by_name = serializers.SerializerMethodField()
|
||||||
@@ -1183,8 +1195,11 @@ class TimeBlockSerializer(serializers.ModelSerializer):
|
|||||||
'reviewed_at', 'review_notes',
|
'reviewed_at', 'review_notes',
|
||||||
'created_by', 'created_by_name',
|
'created_by', 'created_by_name',
|
||||||
'conflict_count', 'created_at', 'updated_at',
|
'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):
|
def get_created_by_name(self, obj):
|
||||||
if obj.created_by:
|
if obj.created_by:
|
||||||
@@ -1426,8 +1441,12 @@ class TimeBlockSerializer(serializers.ModelSerializer):
|
|||||||
return super().create(validated_data)
|
return super().create(validated_data)
|
||||||
|
|
||||||
|
|
||||||
class TimeBlockListSerializer(serializers.ModelSerializer):
|
class TimeBlockListSerializer(TimezoneSerializerMixin, serializers.ModelSerializer):
|
||||||
"""Serializer for time block lists - includes fields needed for editing"""
|
"""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)
|
resource_name = serializers.CharField(source='resource.name', read_only=True, allow_null=True)
|
||||||
created_by_name = serializers.SerializerMethodField()
|
created_by_name = serializers.SerializerMethodField()
|
||||||
reviewed_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',
|
'is_active', 'approval_status', 'reviewed_by', 'reviewed_by_name',
|
||||||
'reviewed_at', 'review_notes',
|
'reviewed_at', 'review_notes',
|
||||||
'created_by', 'created_by_name', 'created_at',
|
'created_by', 'created_by_name', 'created_at',
|
||||||
|
# Timezone context (from TimezoneSerializerMixin)
|
||||||
|
'business_timezone',
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_created_by_name(self, obj):
|
def get_created_by_name(self, obj):
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from smoothschedule.field_mobile.models import (
|
|||||||
EmployeeLocationUpdate,
|
EmployeeLocationUpdate,
|
||||||
FieldCallLog,
|
FieldCallLog,
|
||||||
)
|
)
|
||||||
|
from core.mixins import TimezoneSerializerMixin
|
||||||
|
|
||||||
|
|
||||||
class ServiceSummarySerializer(serializers.ModelSerializer):
|
class ServiceSummarySerializer(serializers.ModelSerializer):
|
||||||
@@ -49,11 +50,12 @@ class CustomerInfoSerializer(serializers.Serializer):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
class JobListSerializer(serializers.ModelSerializer):
|
class JobListSerializer(TimezoneSerializerMixin, serializers.ModelSerializer):
|
||||||
"""
|
"""
|
||||||
Serializer for job list (today's and upcoming jobs).
|
Serializer for job list (today's and upcoming jobs).
|
||||||
|
|
||||||
Optimized for quick loading on mobile.
|
Optimized for quick loading on mobile.
|
||||||
|
Includes business_timezone for proper time display.
|
||||||
"""
|
"""
|
||||||
service_name = serializers.SerializerMethodField()
|
service_name = serializers.SerializerMethodField()
|
||||||
customer_name = serializers.SerializerMethodField()
|
customer_name = serializers.SerializerMethodField()
|
||||||
@@ -76,6 +78,7 @@ class JobListSerializer(serializers.ModelSerializer):
|
|||||||
'address',
|
'address',
|
||||||
'duration_minutes',
|
'duration_minutes',
|
||||||
'allowed_transitions',
|
'allowed_transitions',
|
||||||
|
'business_timezone',
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_service_name(self, obj):
|
def get_service_name(self, obj):
|
||||||
@@ -142,11 +145,12 @@ class JobListSerializer(serializers.ModelSerializer):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
class JobDetailSerializer(serializers.ModelSerializer):
|
class JobDetailSerializer(TimezoneSerializerMixin, serializers.ModelSerializer):
|
||||||
"""
|
"""
|
||||||
Full job details for the job detail screen.
|
Full job details for the job detail screen.
|
||||||
|
|
||||||
Includes all information needed to work on the job.
|
Includes all information needed to work on the job.
|
||||||
|
Includes business_timezone for proper time display.
|
||||||
"""
|
"""
|
||||||
service = ServiceSummarySerializer(read_only=True)
|
service = ServiceSummarySerializer(read_only=True)
|
||||||
customer = serializers.SerializerMethodField()
|
customer = serializers.SerializerMethodField()
|
||||||
@@ -184,6 +188,7 @@ class JobDetailSerializer(serializers.ModelSerializer):
|
|||||||
'created_at',
|
'created_at',
|
||||||
'updated_at',
|
'updated_at',
|
||||||
'can_edit_schedule',
|
'can_edit_schedule',
|
||||||
|
'business_timezone',
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_customer(self, obj):
|
def get_customer(self, obj):
|
||||||
|
|||||||
Reference in New Issue
Block a user