diff --git a/docs/00-SYSTEM/TIMEZONE.md b/docs/00-SYSTEM/TIMEZONE.md new file mode 100644 index 00000000..939a51f5 --- /dev/null +++ b/docs/00-SYSTEM/TIMEZONE.md @@ -0,0 +1,66 @@ +# Timezone Standard + +## Purpose +This document defines the single-source timezone standard for IGNY8 and how all future time-related features must use it. + +## Single Source of Truth +- **Account timezone** is the canonical timezone for all user-visible times and scheduling UI. +- The value is stored on the Account model as `account_timezone` (IANA name, e.g., `America/New_York`). +- Selection modes: + - **Country-derived**: timezone is derived from billing country. + - **Manual**: user picks an IANA timezone that maps to a UTC offset list. + +## Storage Rules +- **Persist timestamps in UTC** in the database. +- **Never store local times** without timezone context. +- Store user selection in `Account.account_timezone` and (when needed) `timezone_mode` and `timezone_offset` for UI display. + +## Display Rules (Frontend) +- All UI formatting must use the account timezone. +- Use shared helpers: + - `getAccountTimezone()` for the active timezone. + - `formatDate()`, `formatDateTime()`, `formatRelativeDate()` for consistent formatting. +- **Do not** call `toLocaleDateString()` or `toLocaleTimeString()` without passing the account timezone. + +## Scheduling Rules +- All scheduling inputs in UI are **account timezone**. +- Convert to UTC before sending to the backend. +- All API payloads for scheduling must send ISO-8601 with timezone offset. +- The backend stores scheduled datetimes in UTC. + +## Backend API Contract +- Endpoints that return timestamps should return UTC ISO strings. +- Endpoints that return “server time” should return **account-local time** for display, plus the account timezone identifier. +- If the account timezone is invalid or missing, fall back to `UTC`. + +## Country List Source +- Country list must be fetched from `/v1/auth/countries/`. +- No hardcoded country lists in UI or backend responses. + +## Implementation Checklist (New Features) +1. **Input**: confirm user inputs are in account timezone. +2. **Conversion**: convert to UTC before persistence or scheduling. +3. **Storage**: store in UTC only. +4. **Output**: format all timestamps with account timezone helpers. +5. **API**: ensure responses include timezone-aware context when needed. + +## Guardrails +- Never introduce a second timezone source per user/site. +- Do not mix server timezone with account timezone in UI. +- Avoid timezone math in the UI; prefer helpers and backend-provided values when possible. + +## Examples +- **Display date in UI**: + - Use `formatDateTime(timestamp)` to render in account timezone. +- **Schedule content**: + - User selects date/time in account timezone → convert to ISO → send to `/schedule/`. + +## Troubleshooting +- If times appear “off”: + - Check account timezone is set. + - Confirm UI uses helpers. + - Confirm backend converts to UTC before save. + +--- +Owner: Platform +Last updated: 2026-01-19 diff --git a/frontend/src/components/Automation/DetailView/MeaningfulRunHistory.tsx b/frontend/src/components/Automation/DetailView/MeaningfulRunHistory.tsx index ac02c701..e90b83fb 100644 --- a/frontend/src/components/Automation/DetailView/MeaningfulRunHistory.tsx +++ b/frontend/src/components/Automation/DetailView/MeaningfulRunHistory.tsx @@ -5,6 +5,7 @@ */ import React from 'react'; import { useNavigate } from 'react-router-dom'; +import { formatDateTime } from '../../../utils/date'; interface StageOutput { stage: number; @@ -99,19 +100,7 @@ const MeaningfulRunHistory: React.FC = ({ return `${minutes}m`; }; - const formatDate = (dateStr: string) => { - try { - const date = new Date(dateStr); - return date.toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit' - }); - } catch { - return dateStr; - } - }; + const formatDate = (dateStr: string) => formatDateTime(dateStr); const getStatusColor = (status: string) => { switch (status) { diff --git a/frontend/src/components/Automation/RunHistory.tsx b/frontend/src/components/Automation/RunHistory.tsx index a90052eb..fde9060b 100644 --- a/frontend/src/components/Automation/RunHistory.tsx +++ b/frontend/src/components/Automation/RunHistory.tsx @@ -5,6 +5,7 @@ import React, { useState, useEffect } from 'react'; import { automationService, RunHistoryItem } from '../../services/automationService'; import ComponentCard from '../common/ComponentCard'; +import { formatDateTime } from '../../utils/date'; interface RunHistoryProps { siteId: number; @@ -98,11 +99,11 @@ const RunHistory: React.FC = ({ siteId }) => { {run.trigger_type || 'manual'} - {run.started_at ? new Date(run.started_at).toLocaleString() : '-'} + {run.started_at ? formatDateTime(run.started_at) : '-'} {run.completed_at - ? new Date(run.completed_at).toLocaleString() + ? formatDateTime(run.completed_at) : '-'} {run.total_credits_used || 0} diff --git a/frontend/src/components/auth/SignInForm.tsx b/frontend/src/components/auth/SignInForm.tsx index 1ea9edd3..9495f125 100644 --- a/frontend/src/components/auth/SignInForm.tsx +++ b/frontend/src/components/auth/SignInForm.tsx @@ -6,6 +6,7 @@ import Input from "../form/input/InputField"; import Checkbox from "../form/input/Checkbox"; import Button from "../ui/button/Button"; import { useAuthStore } from "../../store/authStore"; +import { formatDateTime } from "../../utils/date"; interface LogoutReason { code: string; @@ -158,7 +159,7 @@ export default function SignInForm() {
Code: {logoutReason.code}
Source: {logoutReason.source}
-
Time: {new Date(logoutReason.timestamp).toLocaleString()}
+
Time: {formatDateTime(logoutReason.timestamp)}
{logoutReason.context && Object.keys(logoutReason.context).length > 0 && (
Context: diff --git a/frontend/src/components/billing/BillingRecentTransactions.tsx b/frontend/src/components/billing/BillingRecentTransactions.tsx index dc25ef3c..3b43aa5c 100644 --- a/frontend/src/components/billing/BillingRecentTransactions.tsx +++ b/frontend/src/components/billing/BillingRecentTransactions.tsx @@ -3,6 +3,7 @@ import ComponentCard from '../../components/common/ComponentCard'; import Badge from '../../components/ui/badge/Badge'; import { useToast } from '../../components/ui/toast/ToastContainer'; import { getCreditTransactions, type CreditTransaction } from '../../services/billing.api'; +import { formatDateTime } from '../../utils/date'; type Variant = 'card' | 'plain'; @@ -57,7 +58,7 @@ export default function BillingRecentTransactions({ limit = 10, variant = 'card'
- {new Date(transaction.created_at).toLocaleString()} + {formatDateTime(transaction.created_at)} {transaction.reference_id && ` • Ref: ${transaction.reference_id}`}
diff --git a/frontend/src/components/billing/BillingUsagePanel.tsx b/frontend/src/components/billing/BillingUsagePanel.tsx index 9d08c55c..28220c7e 100644 --- a/frontend/src/components/billing/BillingUsagePanel.tsx +++ b/frontend/src/components/billing/BillingUsagePanel.tsx @@ -5,6 +5,7 @@ import { useBillingStore } from '../../store/billingStore'; import { Card } from '../../components/ui/card'; import Badge from '../../components/ui/badge/Badge'; import { CompactPagination } from '../ui/pagination'; +import { formatDateTime } from '../../utils/date'; // Credit costs per operation (copied from Billing usage page) const CREDIT_COSTS: Record = { @@ -85,7 +86,7 @@ export default function BillingUsagePanel({ showOnlyActivity = false }: BillingU {paginated.map((txn) => ( - {new Date(txn.created_at).toLocaleString()} + {formatDateTime(txn.created_at)} = 0 ? 'success' : 'danger'}>{txn.transaction_type} @@ -232,7 +233,7 @@ export default function BillingUsagePanel({ showOnlyActivity = false }: BillingU {paginated.map((txn) => ( - {new Date(txn.created_at).toLocaleString()} + {formatDateTime(txn.created_at)} = 0 ? 'success' : 'error'}>{txn.transaction_type} diff --git a/frontend/src/components/billing/PaymentHistory.tsx b/frontend/src/components/billing/PaymentHistory.tsx index 879955cd..edaaac57 100644 --- a/frontend/src/components/billing/PaymentHistory.tsx +++ b/frontend/src/components/billing/PaymentHistory.tsx @@ -3,6 +3,7 @@ import { useAuthStore } from '../../store/authStore'; import { API_BASE_URL } from '../../services/api'; import Button from '../ui/button/Button'; import { CheckCircleIcon, XCircleIcon, ClockIcon, RefreshCwIcon } from '../../icons'; +import { formatDateTime } from '../../utils/date'; interface Payment { id: number; @@ -73,15 +74,7 @@ export default function PaymentHistory() { return badges[status as keyof typeof badges] || badges.pending_approval; }; - const formatDate = (dateString: string) => { - return new Date(dateString).toLocaleDateString('en-US', { - year: 'numeric', - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - }); - }; + const formatDate = (dateString: string) => formatDateTime(dateString); if (loading) { return ( diff --git a/frontend/src/components/common/ErrorDetailsModal.tsx b/frontend/src/components/common/ErrorDetailsModal.tsx index b14e6e5d..b2b2a2b2 100644 --- a/frontend/src/components/common/ErrorDetailsModal.tsx +++ b/frontend/src/components/common/ErrorDetailsModal.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { Modal } from '../ui/modal'; import Button from '../ui/button/Button'; import { ErrorIcon, CalendarIcon, BoltIcon, ExternalLinkIcon } from '../../icons'; +import { formatDateTime } from '../../utils/date'; interface Content { id: number; @@ -42,15 +43,7 @@ const ErrorDetailsModal: React.FC = ({ const formatDate = (isoString: string | null) => { if (!isoString) return 'N/A'; try { - const date = new Date(isoString); - return date.toLocaleString('en-US', { - month: 'long', - day: 'numeric', - year: 'numeric', - hour: 'numeric', - minute: '2-digit', - hour12: true - }); + return formatDateTime(isoString); } catch (error) { return isoString; } diff --git a/frontend/src/components/dashboard/AccountInfoWidget.tsx b/frontend/src/components/dashboard/AccountInfoWidget.tsx index 7ae85068..ebf2d7fe 100644 --- a/frontend/src/components/dashboard/AccountInfoWidget.tsx +++ b/frontend/src/components/dashboard/AccountInfoWidget.tsx @@ -13,6 +13,7 @@ import ComponentCard from '../common/ComponentCard'; import Button from '../ui/button/Button'; import Badge from '../ui/badge/Badge'; import { CreditBalance, Subscription, Plan } from '../../services/billing.api'; +import { formatDate } from '../../utils/date'; import { CalendarIcon, CreditCardIcon, @@ -29,18 +30,9 @@ interface AccountInfoWidgetProps { } // Helper to format dates -function formatDate(dateStr: string | undefined): string { +function formatAccountDate(dateStr: string | undefined): string { if (!dateStr) return '—'; - try { - const date = new Date(dateStr); - return date.toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric' - }); - } catch { - return '—'; - } + return formatDate(dateStr); } // Helper to get days until date @@ -162,7 +154,7 @@ export default function AccountInfoWidget({

- {formatDate(periodEnd)} + {formatAccountDate(periodEnd)}

{daysUntilReset !== null && daysUntilReset > 0 && (

@@ -180,7 +172,7 @@ export default function AccountInfoWidget({

- {formatDate(periodEnd)} + {formatAccountDate(periodEnd)}

{currentPlan?.price && (

diff --git a/frontend/src/components/dashboard/AutomationStatusWidget.tsx b/frontend/src/components/dashboard/AutomationStatusWidget.tsx index 0cd91af0..b6df99eb 100644 --- a/frontend/src/components/dashboard/AutomationStatusWidget.tsx +++ b/frontend/src/components/dashboard/AutomationStatusWidget.tsx @@ -12,6 +12,7 @@ import { AlertIcon, ClockIcon, } from '../../icons'; +import { formatDateTime } from '../../utils/date'; export interface AutomationData { status: 'active' | 'paused' | 'failed' | 'not_configured'; @@ -61,16 +62,6 @@ const statusConfig = { }, }; -function formatDateTime(date: Date): string { - return date.toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - hour: 'numeric', - minute: '2-digit', - hour12: true, - }); -} - export default function AutomationStatusWidget({ data, onRunNow, loading }: AutomationStatusWidgetProps) { const config = statusConfig[data.status]; const StatusIcon = config.icon; diff --git a/frontend/src/components/dashboard/RecentActivityWidget.tsx b/frontend/src/components/dashboard/RecentActivityWidget.tsx index 6acdf000..671adaab 100644 --- a/frontend/src/components/dashboard/RecentActivityWidget.tsx +++ b/frontend/src/components/dashboard/RecentActivityWidget.tsx @@ -14,6 +14,7 @@ import { AlertIcon, CheckCircleIcon, } from '../../icons'; +import { formatDate } from '../../utils/date'; export interface ActivityItem { id: string; @@ -68,7 +69,7 @@ function formatRelativeTime(date: Date): string { if (diffDays === 1) return 'Yesterday'; if (diffDays < 7) return `${diffDays}d ago`; - return date.toLocaleDateString(); + return formatDate(date); } export default function RecentActivityWidget({ activities, loading }: RecentActivityWidgetProps) { diff --git a/frontend/src/components/integration/IntegrationStatus.tsx b/frontend/src/components/integration/IntegrationStatus.tsx index b38b11c2..062ebf59 100644 --- a/frontend/src/components/integration/IntegrationStatus.tsx +++ b/frontend/src/components/integration/IntegrationStatus.tsx @@ -4,6 +4,7 @@ */ import React from 'react'; import { CheckCircleIcon, XCircleIcon, ClockIcon, RefreshCwIcon } from '../../icons'; +import { formatDateTime } from '../../utils/date'; interface IntegrationStatusProps { syncEnabled: boolean; @@ -47,8 +48,7 @@ export default function IntegrationStatus({ const formatDate = (dateString: string | null) => { if (!dateString) return 'Never'; try { - const date = new Date(dateString); - return date.toLocaleString(); + return formatDateTime(dateString); } catch { return 'Invalid Date'; } diff --git a/frontend/src/components/sites/WordPressIntegrationCard.tsx b/frontend/src/components/sites/WordPressIntegrationCard.tsx index 4691ac0f..bb532ee3 100644 --- a/frontend/src/components/sites/WordPressIntegrationCard.tsx +++ b/frontend/src/components/sites/WordPressIntegrationCard.tsx @@ -10,6 +10,7 @@ import { Card } from '../ui/card'; import Button from '../ui/button/Button'; import IconButton from '../ui/button/IconButton'; import Badge from '../ui/badge/Badge'; +import { formatDate } from '../../utils/date'; interface WordPressIntegration { id: number; @@ -122,7 +123,7 @@ export default function WordPressIntegrationCard({

Last Sync

{integration.last_sync_at - ? new Date(integration.last_sync_at).toLocaleDateString() + ? formatDate(integration.last_sync_at) : 'Never'}

diff --git a/frontend/src/components/ui/tooltip/CalendarItemTooltip.tsx b/frontend/src/components/ui/tooltip/CalendarItemTooltip.tsx index f5d632e3..2edc3d1a 100644 --- a/frontend/src/components/ui/tooltip/CalendarItemTooltip.tsx +++ b/frontend/src/components/ui/tooltip/CalendarItemTooltip.tsx @@ -4,6 +4,7 @@ */ import React, { ReactNode } from 'react'; import { EnhancedTooltip } from './EnhancedTooltip'; +import { formatDateTime } from '../../../utils/date'; interface CalendarItemTooltipProps { children: ReactNode; @@ -63,7 +64,7 @@ export const CalendarItemTooltip: React.FC = ({ {contentType &&
Type: {contentType}
} {date && (
- {dateLabel}: {new Date(date).toLocaleString()} + {dateLabel}: {formatDateTime(date)}
)} {wordCount !== undefined &&
Words: {wordCount}
} diff --git a/frontend/src/pages/Settings/WordPressIntegrationDebug.tsx b/frontend/src/pages/Settings/WordPressIntegrationDebug.tsx index 323a5e11..bdbbd1f2 100644 --- a/frontend/src/pages/Settings/WordPressIntegrationDebug.tsx +++ b/frontend/src/pages/Settings/WordPressIntegrationDebug.tsx @@ -14,6 +14,7 @@ import { import { useSiteStore } from '../../store/siteStore'; import { useToast } from '../../components/ui/toast/ToastContainer'; import { API_BASE_URL, fetchAPI } from '../../services/api'; +import { formatTime } from '../../utils/date'; // Types for WordPress integration debugging interface IntegrationHealth { @@ -498,7 +499,7 @@ export default function WordPressIntegrationDebug() {

{integrationHealth.api_message}

- Last: {new Date(integrationHealth.last_api_check).toLocaleTimeString()} + Last: {formatTime(integrationHealth.last_api_check)}
@@ -533,7 +534,7 @@ export default function WordPressIntegrationDebug() {

Last: {integrationHealth.last_sync ? - new Date(integrationHealth.last_sync).toLocaleTimeString() : 'Never'} + formatTime(integrationHealth.last_sync) : 'Never'}
@@ -579,7 +580,7 @@ export default function WordPressIntegrationDebug() { )}
- {new Date(event.timestamp).toLocaleTimeString()} + {formatTime(event.timestamp)}
diff --git a/frontend/src/pages/account/NotificationsPage.tsx b/frontend/src/pages/account/NotificationsPage.tsx index 2a57beb2..de16e097 100644 --- a/frontend/src/pages/account/NotificationsPage.tsx +++ b/frontend/src/pages/account/NotificationsPage.tsx @@ -22,6 +22,7 @@ import { useNotificationStore } from '../../store/notificationStore'; import { usePageLoading } from '../../context/PageLoadingContext'; import type { NotificationAPI } from '../../services/notifications.api'; import { deleteNotification as deleteNotificationAPI } from '../../services/notifications.api'; +import { formatDateTime } from '../../utils/date'; interface FilterState { severity: string; @@ -101,13 +102,7 @@ export default function NotificationsPage() { const days = Math.floor(hours / 24); if (days > 7) { - return date.toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - hour: 'numeric', - minute: '2-digit' - }); + return formatDateTime(date); } else if (days > 0) { return `${days} day${days !== 1 ? 's' : ''} ago`; } else if (hours > 0) { diff --git a/frontend/src/utils/date.ts b/frontend/src/utils/date.ts index df4eec1c..b352c274 100644 --- a/frontend/src/utils/date.ts +++ b/frontend/src/utils/date.ts @@ -142,3 +142,23 @@ export function formatDateTime( }); } +/** + * Format time in account timezone + * @param dateString - ISO date string or Date object + * @param options - Intl.DateTimeFormat options override + */ +export function formatTime( + dateString: string | Date | null | undefined, + options: Intl.DateTimeFormatOptions = { hour: 'numeric', minute: '2-digit', second: '2-digit' } +): string { + if (!dateString) return '-'; + + const date = typeof dateString === 'string' ? new Date(dateString) : dateString; + if (isNaN(date.getTime())) return '-'; + + return date.toLocaleTimeString('en-US', { + ...options, + timeZone: getAccountTimezone(), + }); +} +