From f4332153f4fdb3c5d48cc0ac3cdcd4cd73efdc56 Mon Sep 17 00:00:00 2001 From: poduck Date: Sun, 7 Dec 2025 19:39:36 -0500 Subject: [PATCH] feat: Add timezone architecture for consistent date/time handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CLAUDE.md | 116 ++++++ frontend/src/components/EditTaskModal.tsx | 3 +- .../time-blocks/TimeBlockCreatorModal.tsx | 5 +- .../time-blocks/YearlyBlockCalendar.tsx | 3 +- frontend/src/pages/OwnerScheduler.tsx | 5 +- .../src/pages/settings/GeneralSettings.tsx | 162 ++++++-- frontend/src/utils/dateUtils.ts | 393 ++++++++++++++++++ smoothschedule/core/mixins.py | 95 ++++- smoothschedule/schedule/serializers.py | 35 +- .../field_mobile/serializers.py | 9 +- 10 files changed, 783 insertions(+), 43 deletions(-) create mode 100644 frontend/src/utils/dateUtils.ts diff --git a/CLAUDE.md b/CLAUDE.md index 27be45f..5fd17a7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,6 +61,122 @@ docker compose -f docker-compose.local.yml exec django python manage.py = ({ 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') { diff --git a/frontend/src/components/time-blocks/TimeBlockCreatorModal.tsx b/frontend/src/components/time-blocks/TimeBlockCreatorModal.tsx index 6a64a2a..3403290 100644 --- a/frontend/src/components/time-blocks/TimeBlockCreatorModal.tsx +++ b/frontend/src/components/time-blocks/TimeBlockCreatorModal.tsx @@ -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 = ({ 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 }; diff --git a/frontend/src/components/time-blocks/YearlyBlockCalendar.tsx b/frontend/src/components/time-blocks/YearlyBlockCalendar.tsx index 0565944..5aee1d9 100644 --- a/frontend/src/components/time-blocks/YearlyBlockCalendar.tsx +++ b/frontend/src/components/time-blocks/YearlyBlockCalendar.tsx @@ -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 = ({ return
; } - 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(); diff --git a/frontend/src/pages/OwnerScheduler.tsx b/frontend/src/pages/OwnerScheduler.tsx index 4bfd3c1..1f23b78 100644 --- a/frontend/src/pages/OwnerScheduler.tsx +++ b/frontend/src/pages/OwnerScheduler.tsx @@ -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 = ({ 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); diff --git a/frontend/src/pages/settings/GeneralSettings.tsx b/frontend/src/pages/settings/GeneralSettings.tsx index 98323f2..7d72345 100644 --- a/frontend/src/pages/settings/GeneralSettings.tsx +++ b/frontend/src/pages/settings/GeneralSettings.tsx @@ -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 => ( - + {timezoneGroups.map(group => ( + + {group.timezones.map(tz => ( + + ))} + ))}

diff --git a/frontend/src/utils/dateUtils.ts b/frontend/src/utils/dateUtils.ts new file mode 100644 index 0000000..ac62159 --- /dev/null +++ b/frontend/src/utils/dateUtils.ts @@ -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})`; +}; diff --git a/smoothschedule/core/mixins.py b/smoothschedule/core/mixins.py index e301884..4d6c4f5 100644 --- a/smoothschedule/core/mixins.py +++ b/smoothschedule/core/mixins.py @@ -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 diff --git a/smoothschedule/schedule/serializers.py b/smoothschedule/schedule/serializers.py index a8f3166..859aad0 100644 --- a/smoothschedule/schedule/serializers.py +++ b/smoothschedule/schedule/serializers.py @@ -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.users.models import User +from 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): diff --git a/smoothschedule/smoothschedule/field_mobile/serializers.py b/smoothschedule/smoothschedule/field_mobile/serializers.py index c5fa534..09448c1 100644 --- a/smoothschedule/smoothschedule/field_mobile/serializers.py +++ b/smoothschedule/smoothschedule/field_mobile/serializers.py @@ -12,6 +12,7 @@ from smoothschedule.field_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):