tiemzone udpate on all pages that requrie timezone updating

This commit is contained in:
IGNY8 VPS (Salman)
2026-01-19 16:51:58 +00:00
parent 27afc63d88
commit c61eae051b
17 changed files with 122 additions and 75 deletions

View File

@@ -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

View File

@@ -5,6 +5,7 @@
*/ */
import React from 'react'; import React from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { formatDateTime } from '../../../utils/date';
interface StageOutput { interface StageOutput {
stage: number; stage: number;
@@ -99,19 +100,7 @@ const MeaningfulRunHistory: React.FC<MeaningfulRunHistoryProps> = ({
return `${minutes}m`; return `${minutes}m`;
}; };
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => formatDateTime(dateStr);
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 getStatusColor = (status: string) => { const getStatusColor = (status: string) => {
switch (status) { switch (status) {

View File

@@ -5,6 +5,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { automationService, RunHistoryItem } from '../../services/automationService'; import { automationService, RunHistoryItem } from '../../services/automationService';
import ComponentCard from '../common/ComponentCard'; import ComponentCard from '../common/ComponentCard';
import { formatDateTime } from '../../utils/date';
interface RunHistoryProps { interface RunHistoryProps {
siteId: number; siteId: number;
@@ -98,11 +99,11 @@ const RunHistory: React.FC<RunHistoryProps> = ({ siteId }) => {
</td> </td>
<td className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100 capitalize">{run.trigger_type || 'manual'}</td> <td className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100 capitalize">{run.trigger_type || 'manual'}</td>
<td className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100"> <td className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">
{run.started_at ? new Date(run.started_at).toLocaleString() : '-'} {run.started_at ? formatDateTime(run.started_at) : '-'}
</td> </td>
<td className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100"> <td className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">
{run.completed_at {run.completed_at
? new Date(run.completed_at).toLocaleString() ? formatDateTime(run.completed_at)
: '-'} : '-'}
</td> </td>
<td className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">{run.total_credits_used || 0}</td> <td className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">{run.total_credits_used || 0}</td>

View File

@@ -6,6 +6,7 @@ import Input from "../form/input/InputField";
import Checkbox from "../form/input/Checkbox"; import Checkbox from "../form/input/Checkbox";
import Button from "../ui/button/Button"; import Button from "../ui/button/Button";
import { useAuthStore } from "../../store/authStore"; import { useAuthStore } from "../../store/authStore";
import { formatDateTime } from "../../utils/date";
interface LogoutReason { interface LogoutReason {
code: string; code: string;
@@ -158,7 +159,7 @@ export default function SignInForm() {
<div className="space-y-1 text-xs font-mono text-warning-700 dark:text-warning-400"> <div className="space-y-1 text-xs font-mono text-warning-700 dark:text-warning-400">
<div><span className="font-bold">Code:</span> {logoutReason.code}</div> <div><span className="font-bold">Code:</span> {logoutReason.code}</div>
<div><span className="font-bold">Source:</span> {logoutReason.source}</div> <div><span className="font-bold">Source:</span> {logoutReason.source}</div>
<div><span className="font-bold">Time:</span> {new Date(logoutReason.timestamp).toLocaleString()}</div> <div><span className="font-bold">Time:</span> {formatDateTime(logoutReason.timestamp)}</div>
{logoutReason.context && Object.keys(logoutReason.context).length > 0 && ( {logoutReason.context && Object.keys(logoutReason.context).length > 0 && (
<div className="mt-2"> <div className="mt-2">
<span className="font-bold">Context:</span> <span className="font-bold">Context:</span>

View File

@@ -3,6 +3,7 @@ import ComponentCard from '../../components/common/ComponentCard';
import Badge from '../../components/ui/badge/Badge'; import Badge from '../../components/ui/badge/Badge';
import { useToast } from '../../components/ui/toast/ToastContainer'; import { useToast } from '../../components/ui/toast/ToastContainer';
import { getCreditTransactions, type CreditTransaction } from '../../services/billing.api'; import { getCreditTransactions, type CreditTransaction } from '../../services/billing.api';
import { formatDateTime } from '../../utils/date';
type Variant = 'card' | 'plain'; type Variant = 'card' | 'plain';
@@ -57,7 +58,7 @@ export default function BillingRecentTransactions({ limit = 10, variant = 'card'
</span> </span>
</div> </div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1"> <div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{new Date(transaction.created_at).toLocaleString()} {formatDateTime(transaction.created_at)}
{transaction.reference_id && ` • Ref: ${transaction.reference_id}`} {transaction.reference_id && ` • Ref: ${transaction.reference_id}`}
</div> </div>
</div> </div>

View File

@@ -5,6 +5,7 @@ import { useBillingStore } from '../../store/billingStore';
import { Card } from '../../components/ui/card'; import { Card } from '../../components/ui/card';
import Badge from '../../components/ui/badge/Badge'; import Badge from '../../components/ui/badge/Badge';
import { CompactPagination } from '../ui/pagination'; import { CompactPagination } from '../ui/pagination';
import { formatDateTime } from '../../utils/date';
// Credit costs per operation (copied from Billing usage page) // Credit costs per operation (copied from Billing usage page)
const CREDIT_COSTS: Record<string, { cost: number | string; description: string }> = { const CREDIT_COSTS: Record<string, { cost: number | string; description: string }> = {
@@ -85,7 +86,7 @@ export default function BillingUsagePanel({ showOnlyActivity = false }: BillingU
<tbody> <tbody>
{paginated.map((txn) => ( {paginated.map((txn) => (
<tr key={txn.id} className="border-b border-gray-100 dark:border-gray-800"> <tr key={txn.id} className="border-b border-gray-100 dark:border-gray-800">
<td className="py-3 px-4 text-sm text-gray-900 dark:text-white">{new Date(txn.created_at).toLocaleString()}</td> <td className="py-3 px-4 text-sm text-gray-900 dark:text-white">{formatDateTime(txn.created_at)}</td>
<td className="py-3 px-4"> <td className="py-3 px-4">
<Badge variant="soft" tone={txn.amount >= 0 ? 'success' : 'danger'}>{txn.transaction_type}</Badge> <Badge variant="soft" tone={txn.amount >= 0 ? 'success' : 'danger'}>{txn.transaction_type}</Badge>
</td> </td>
@@ -232,7 +233,7 @@ export default function BillingUsagePanel({ showOnlyActivity = false }: BillingU
<tbody> <tbody>
{paginated.map((txn) => ( {paginated.map((txn) => (
<tr key={txn.id} className="border-b border-gray-100 dark:border-gray-800"> <tr key={txn.id} className="border-b border-gray-100 dark:border-gray-800">
<td className="py-3 px-4 text-sm text-gray-900 dark:text-white">{new Date(txn.created_at).toLocaleString()}</td> <td className="py-3 px-4 text-sm text-gray-900 dark:text-white">{formatDateTime(txn.created_at)}</td>
<td className="py-3 px-4"> <td className="py-3 px-4">
<Badge variant="light" color={txn.amount >= 0 ? 'success' : 'error'}>{txn.transaction_type}</Badge> <Badge variant="light" color={txn.amount >= 0 ? 'success' : 'error'}>{txn.transaction_type}</Badge>
</td> </td>

View File

@@ -3,6 +3,7 @@ import { useAuthStore } from '../../store/authStore';
import { API_BASE_URL } from '../../services/api'; import { API_BASE_URL } from '../../services/api';
import Button from '../ui/button/Button'; import Button from '../ui/button/Button';
import { CheckCircleIcon, XCircleIcon, ClockIcon, RefreshCwIcon } from '../../icons'; import { CheckCircleIcon, XCircleIcon, ClockIcon, RefreshCwIcon } from '../../icons';
import { formatDateTime } from '../../utils/date';
interface Payment { interface Payment {
id: number; id: number;
@@ -73,15 +74,7 @@ export default function PaymentHistory() {
return badges[status as keyof typeof badges] || badges.pending_approval; return badges[status as keyof typeof badges] || badges.pending_approval;
}; };
const formatDate = (dateString: string) => { const formatDate = (dateString: string) => formatDateTime(dateString);
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
if (loading) { if (loading) {
return ( return (

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { Modal } from '../ui/modal'; import { Modal } from '../ui/modal';
import Button from '../ui/button/Button'; import Button from '../ui/button/Button';
import { ErrorIcon, CalendarIcon, BoltIcon, ExternalLinkIcon } from '../../icons'; import { ErrorIcon, CalendarIcon, BoltIcon, ExternalLinkIcon } from '../../icons';
import { formatDateTime } from '../../utils/date';
interface Content { interface Content {
id: number; id: number;
@@ -42,15 +43,7 @@ const ErrorDetailsModal: React.FC<ErrorDetailsModalProps> = ({
const formatDate = (isoString: string | null) => { const formatDate = (isoString: string | null) => {
if (!isoString) return 'N/A'; if (!isoString) return 'N/A';
try { try {
const date = new Date(isoString); return formatDateTime(isoString);
return date.toLocaleString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true
});
} catch (error) { } catch (error) {
return isoString; return isoString;
} }

View File

@@ -13,6 +13,7 @@ import ComponentCard from '../common/ComponentCard';
import Button from '../ui/button/Button'; import Button from '../ui/button/Button';
import Badge from '../ui/badge/Badge'; import Badge from '../ui/badge/Badge';
import { CreditBalance, Subscription, Plan } from '../../services/billing.api'; import { CreditBalance, Subscription, Plan } from '../../services/billing.api';
import { formatDate } from '../../utils/date';
import { import {
CalendarIcon, CalendarIcon,
CreditCardIcon, CreditCardIcon,
@@ -29,18 +30,9 @@ interface AccountInfoWidgetProps {
} }
// Helper to format dates // Helper to format dates
function formatDate(dateStr: string | undefined): string { function formatAccountDate(dateStr: string | undefined): string {
if (!dateStr) return '—'; if (!dateStr) return '—';
try { return formatDate(dateStr);
const date = new Date(dateStr);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
} catch {
return '—';
}
} }
// Helper to get days until date // Helper to get days until date
@@ -162,7 +154,7 @@ export default function AccountInfoWidget({
</p> </p>
</div> </div>
<p className="text-base font-semibold text-gray-900 dark:text-white"> <p className="text-base font-semibold text-gray-900 dark:text-white">
{formatDate(periodEnd)} {formatAccountDate(periodEnd)}
</p> </p>
{daysUntilReset !== null && daysUntilReset > 0 && ( {daysUntilReset !== null && daysUntilReset > 0 && (
<p className="text-sm text-gray-400 dark:text-gray-500"> <p className="text-sm text-gray-400 dark:text-gray-500">
@@ -180,7 +172,7 @@ export default function AccountInfoWidget({
</p> </p>
</div> </div>
<p className="text-base font-semibold text-gray-900 dark:text-white"> <p className="text-base font-semibold text-gray-900 dark:text-white">
{formatDate(periodEnd)} {formatAccountDate(periodEnd)}
</p> </p>
{currentPlan?.price && ( {currentPlan?.price && (
<p className="text-sm text-gray-400 dark:text-gray-500"> <p className="text-sm text-gray-400 dark:text-gray-500">

View File

@@ -12,6 +12,7 @@ import {
AlertIcon, AlertIcon,
ClockIcon, ClockIcon,
} from '../../icons'; } from '../../icons';
import { formatDateTime } from '../../utils/date';
export interface AutomationData { export interface AutomationData {
status: 'active' | 'paused' | 'failed' | 'not_configured'; 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) { export default function AutomationStatusWidget({ data, onRunNow, loading }: AutomationStatusWidgetProps) {
const config = statusConfig[data.status]; const config = statusConfig[data.status];
const StatusIcon = config.icon; const StatusIcon = config.icon;

View File

@@ -14,6 +14,7 @@ import {
AlertIcon, AlertIcon,
CheckCircleIcon, CheckCircleIcon,
} from '../../icons'; } from '../../icons';
import { formatDate } from '../../utils/date';
export interface ActivityItem { export interface ActivityItem {
id: string; id: string;
@@ -68,7 +69,7 @@ function formatRelativeTime(date: Date): string {
if (diffDays === 1) return 'Yesterday'; if (diffDays === 1) return 'Yesterday';
if (diffDays < 7) return `${diffDays}d ago`; if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString(); return formatDate(date);
} }
export default function RecentActivityWidget({ activities, loading }: RecentActivityWidgetProps) { export default function RecentActivityWidget({ activities, loading }: RecentActivityWidgetProps) {

View File

@@ -4,6 +4,7 @@
*/ */
import React from 'react'; import React from 'react';
import { CheckCircleIcon, XCircleIcon, ClockIcon, RefreshCwIcon } from '../../icons'; import { CheckCircleIcon, XCircleIcon, ClockIcon, RefreshCwIcon } from '../../icons';
import { formatDateTime } from '../../utils/date';
interface IntegrationStatusProps { interface IntegrationStatusProps {
syncEnabled: boolean; syncEnabled: boolean;
@@ -47,8 +48,7 @@ export default function IntegrationStatus({
const formatDate = (dateString: string | null) => { const formatDate = (dateString: string | null) => {
if (!dateString) return 'Never'; if (!dateString) return 'Never';
try { try {
const date = new Date(dateString); return formatDateTime(dateString);
return date.toLocaleString();
} catch { } catch {
return 'Invalid Date'; return 'Invalid Date';
} }

View File

@@ -10,6 +10,7 @@ import { Card } from '../ui/card';
import Button from '../ui/button/Button'; import Button from '../ui/button/Button';
import IconButton from '../ui/button/IconButton'; import IconButton from '../ui/button/IconButton';
import Badge from '../ui/badge/Badge'; import Badge from '../ui/badge/Badge';
import { formatDate } from '../../utils/date';
interface WordPressIntegration { interface WordPressIntegration {
id: number; id: number;
@@ -122,7 +123,7 @@ export default function WordPressIntegrationCard({
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Last Sync</p> <p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Last Sync</p>
<p className="text-sm text-gray-900 dark:text-white"> <p className="text-sm text-gray-900 dark:text-white">
{integration.last_sync_at {integration.last_sync_at
? new Date(integration.last_sync_at).toLocaleDateString() ? formatDate(integration.last_sync_at)
: 'Never'} : 'Never'}
</p> </p>
</div> </div>

View File

@@ -4,6 +4,7 @@
*/ */
import React, { ReactNode } from 'react'; import React, { ReactNode } from 'react';
import { EnhancedTooltip } from './EnhancedTooltip'; import { EnhancedTooltip } from './EnhancedTooltip';
import { formatDateTime } from '../../../utils/date';
interface CalendarItemTooltipProps { interface CalendarItemTooltipProps {
children: ReactNode; children: ReactNode;
@@ -63,7 +64,7 @@ export const CalendarItemTooltip: React.FC<CalendarItemTooltipProps> = ({
{contentType && <div>Type: {contentType}</div>} {contentType && <div>Type: {contentType}</div>}
{date && ( {date && (
<div> <div>
{dateLabel}: {new Date(date).toLocaleString()} {dateLabel}: {formatDateTime(date)}
</div> </div>
)} )}
{wordCount !== undefined && <div>Words: {wordCount}</div>} {wordCount !== undefined && <div>Words: {wordCount}</div>}

View File

@@ -14,6 +14,7 @@ import {
import { useSiteStore } from '../../store/siteStore'; import { useSiteStore } from '../../store/siteStore';
import { useToast } from '../../components/ui/toast/ToastContainer'; import { useToast } from '../../components/ui/toast/ToastContainer';
import { API_BASE_URL, fetchAPI } from '../../services/api'; import { API_BASE_URL, fetchAPI } from '../../services/api';
import { formatTime } from '../../utils/date';
// Types for WordPress integration debugging // Types for WordPress integration debugging
interface IntegrationHealth { interface IntegrationHealth {
@@ -498,7 +499,7 @@ export default function WordPressIntegrationDebug() {
</div> </div>
<p className="text-xs text-gray-600 dark:text-gray-400">{integrationHealth.api_message}</p> <p className="text-xs text-gray-600 dark:text-gray-400">{integrationHealth.api_message}</p>
<div className="text-xs text-gray-500 mt-1"> <div className="text-xs text-gray-500 mt-1">
Last: {new Date(integrationHealth.last_api_check).toLocaleTimeString()} Last: {formatTime(integrationHealth.last_api_check)}
</div> </div>
</div> </div>
@@ -533,7 +534,7 @@ export default function WordPressIntegrationDebug() {
</p> </p>
<div className="text-xs text-gray-500 mt-1"> <div className="text-xs text-gray-500 mt-1">
Last: {integrationHealth.last_sync ? Last: {integrationHealth.last_sync ?
new Date(integrationHealth.last_sync).toLocaleTimeString() : 'Never'} formatTime(integrationHealth.last_sync) : 'Never'}
</div> </div>
</div> </div>
</div> </div>
@@ -579,7 +580,7 @@ export default function WordPressIntegrationDebug() {
)} )}
</div> </div>
<div className="text-xs text-gray-500 whitespace-nowrap ml-4"> <div className="text-xs text-gray-500 whitespace-nowrap ml-4">
{new Date(event.timestamp).toLocaleTimeString()} {formatTime(event.timestamp)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -22,6 +22,7 @@ import { useNotificationStore } from '../../store/notificationStore';
import { usePageLoading } from '../../context/PageLoadingContext'; import { usePageLoading } from '../../context/PageLoadingContext';
import type { NotificationAPI } from '../../services/notifications.api'; import type { NotificationAPI } from '../../services/notifications.api';
import { deleteNotification as deleteNotificationAPI } from '../../services/notifications.api'; import { deleteNotification as deleteNotificationAPI } from '../../services/notifications.api';
import { formatDateTime } from '../../utils/date';
interface FilterState { interface FilterState {
severity: string; severity: string;
@@ -101,13 +102,7 @@ export default function NotificationsPage() {
const days = Math.floor(hours / 24); const days = Math.floor(hours / 24);
if (days > 7) { if (days > 7) {
return date.toLocaleDateString('en-US', { return formatDateTime(date);
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit'
});
} else if (days > 0) { } else if (days > 0) {
return `${days} day${days !== 1 ? 's' : ''} ago`; return `${days} day${days !== 1 ? 's' : ''} ago`;
} else if (hours > 0) { } else if (hours > 0) {

View File

@@ -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(),
});
}