Frontend timezone issues (#2810)

* Use dayjs with UTC plugin for date-formatter

* Remove 'toHuman' function

* Use dayjs dates in Datepicker component

* Use util/date.js functions in date formatter

* Remove `fromJSDate`

* Use formatISO instead of raw dayjs formatter
This commit is contained in:
Uku Taht 2023-04-07 15:56:02 +03:00 committed by GitHub
parent 154ce3a44c
commit e672ea66ec
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 74 additions and 146 deletions

View File

@ -48,8 +48,8 @@ export function serializeQuery(query, extraQuery=[]) {
if (query.comparison) {
queryObj.comparison = query.comparison
queryObj.compare_from = query.compare_from
queryObj.compare_to = query.compare_to
queryObj.compare_from = query.compare_from ? formatISO(query.compare_from) : undefined
queryObj.compare_to = query.compare_to ? formatISO(query.compare_to) : undefined
}
Object.assign(queryObj, ...extraQuery)

View File

@ -6,7 +6,7 @@ import { ChevronDownIcon } from '@heroicons/react/20/solid'
import classNames from 'classnames'
import * as storage from './util/storage'
import Flatpickr from 'react-flatpickr'
import { formatISO, parseUTCDate, formatDayShort } from './util/date.js'
import { formatISO, parseUTCDate } from './util/date.js'
const COMPARISON_MODES = {
'off': 'Disable comparison',
@ -92,9 +92,9 @@ const ComparisonInput = function({ site, query, history }) {
const buildLabel = (query) => {
if (query.comparison == "custom") {
const from = parseUTCDate(query.compare_from)
const to = parseUTCDate(query.compare_to)
return `${formatDayShort(from, false)} - ${formatDayShort(to, false)}`
const from = query.compare_from.format('D MMM')
const to = query.compare_to.format('D MMM')
return `${from} - ${to}`
} else {
return COMPARISON_MODES[query.comparison]
}
@ -116,7 +116,7 @@ const ComparisonInput = function({ site, query, history }) {
static: true,
onClose: ([from, to], _dateStr, _instance) => {
setUiMode("menu")
if (from && to) updateMode("custom", formatISO(from), formatISO(to))
if (from && to) updateMode("custom", formatISO(parseUTCDate(from)), formatISO(parseUTCDate(to)))
}
}

View File

@ -19,7 +19,7 @@ import {
isThisYear,
parseUTCDate,
isBefore,
isAfter,
isAfter
} from "./util/date";
import { navigateToQuery, QueryLink, QueryButton } from "./query";
import { shouldIgnoreKeypress } from "./keybinding.js"
@ -245,7 +245,7 @@ function DatePicker({query, site, history}) {
function setCustomDate(dates) {
if (dates.length === 2) {
const [from, to] = dates
const [from, to] = dates.map(parseUTCDate)
if (formatISO(from) === formatISO(to)) {
navigateToQuery(
history,

View File

@ -1,9 +1,14 @@
import React from 'react'
import { Link, withRouter } from 'react-router-dom'
import {formatDay, formatMonthYYYY, nowForSite, parseUTCDate} from './util/date'
import {nowForSite} from './util/date'
import * as storage from './util/storage'
import { COMPARISON_DISABLED_PERIODS, getStoredComparisonMode, isComparisonEnabled } from './comparison-input'
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
dayjs.extend(utc)
const PERIODS = ['realtime', 'day', 'month', '7d', '30d', '6mo', '12mo', 'year', 'all', 'custom']
export function parseQuery(querystring, site) {
@ -25,11 +30,11 @@ export function parseQuery(querystring, site) {
return {
period,
comparison,
compare_from: q.get('compare_from'),
compare_to: q.get('compare_to'),
date: q.get('date') ? parseUTCDate(q.get('date')) : nowForSite(site),
from: q.get('from') ? parseUTCDate(q.get('from')) : undefined,
to: q.get('to') ? parseUTCDate(q.get('to')) : undefined,
compare_from: q.get('compare_from') ? dayjs.utc(q.get('compare_from')) : undefined,
compare_to: q.get('compare_to') ? dayjs.utc(q.get('compare_to')) : undefined,
date: q.get('date') ? dayjs.utc(q.get('date')) : nowForSite(site),
from: q.get('from') ? dayjs.utc(q.get('from')) : undefined,
to: q.get('to') ? dayjs.utc(q.get('to')) : undefined,
with_imported: q.get('with_imported') ? q.get('with_imported') === 'true' : true,
filters: {
'goal': q.get('goal'),
@ -135,23 +140,6 @@ function QueryButton({history, query, to, disabled, className, children, onClick
const QueryButtonWithRouter = withRouter(QueryButton)
export { QueryButtonWithRouter as QueryButton };
export function toHuman(query) {
if (query.period === 'day') {
return `on ${formatDay(query.date)}`
} if (query.period === 'month') {
return `in ${formatMonthYYYY(query.date)}`
} if (query.period === '7d') {
return 'in the last 7 days'
} if (query.period === '30d') {
return 'in the last 30 days'
} if (query.period === '6mo') {
return 'in the last 6 months'
} if (query.period === '12mo') {
return 'in the last 12 months'
}
return ''
}
export function eventName(query) {
if (query.filters.goal) {
if (query.filters.goal.startsWith('Visit ')) {

View File

@ -1,4 +1,4 @@
import { parseUTCDate, formatMonthYYYY, formatDay, formatDayShort } from '../../util/date'
import {parseUTCDate, formatMonthYYYY, formatDayShort} from '../../util/date'
const browserDateFormat = Intl.DateTimeFormat(navigator.language, { hour: 'numeric' })
@ -6,32 +6,13 @@ const is12HourClock = function () {
return browserDateFormat.resolvedOptions().hour12
}
const parseISODate = function (isoDate) {
const date = parseUTCDate(isoDate)
const minutes = date.getMinutes();
const year = date.getFullYear()
return { date, minutes, year }
}
const getYearString = (options, year) => options.shouldShowYear ? ` ${year}` : ''
const formatHours = function (isoDate) {
const monthIndex = 1
const dateParts = isoDate.split(/[^0-9]/);
dateParts[monthIndex] = dateParts[monthIndex] - 1
const localDate = new Date(...dateParts)
return browserDateFormat.format(localDate)
}
const monthIntervalFormatter = {
long(isoDate, options) {
const formatted = this.short(isoDate, options)
return options.isBucketPartial ? `Partial of ${formatted}` : formatted
},
short(isoDate, _options) {
const { date } = parseISODate(isoDate)
return formatMonthYYYY(date)
return formatMonthYYYY(parseUTCDate(isoDate))
}
}
@ -41,19 +22,16 @@ const weekIntervalFormatter = {
return options.isBucketPartial ? `Partial week of ${formatted}` : `Week of ${formatted}`
},
short(isoDate, options) {
const { date, year } = parseISODate(isoDate)
return `${formatDayShort(date)}${getYearString(options, year)}`
return formatDayShort(parseUTCDate(isoDate), options.shouldShowYear)
}
}
const dateIntervalFormatter = {
long(isoDate, _options) {
const { date } = parseISODate(isoDate)
return formatDay(date)
return parseUTCDate(isoDate).format('ddd, D MMM')
},
short(isoDate, options) {
const { date, year } = parseISODate(isoDate)
return `${formatDayShort(date)}${getYearString(options, year)}`
return formatDayShort(parseUTCDate(isoDate), options.shouldShowYear)
}
}
@ -62,12 +40,10 @@ const hourIntervalFormatter = {
return this.short(isoDate, options)
},
short(isoDate, _options) {
const formatted = formatHours(isoDate)
if (is12HourClock()) {
return formatted.replace(' ', '').toLowerCase()
return parseUTCDate(isoDate).format('ha')
} else {
return formatted.replace(/[^0-9]/g, '').concat(":00")
return parseUTCDate(isoDate).format('HH:mm')
}
}
}
@ -84,12 +60,10 @@ const minuteIntervalFormatter = {
short(isoDate, options) {
if (options.period === 'realtime') return isoDate + 'm'
const { minutes } = parseISODate(isoDate)
const formatted = formatHours(isoDate)
if (is12HourClock()) {
return formatted.replace(' ', ':' + (minutes < 10 ? `0${minutes}` : minutes)).toLowerCase()
return parseUTCDate(isoDate).format('h:mma')
} else {
return formatted.replace(/[^0-9]/g, '').concat(":" + (minutes < 10 ? `0${minutes}` : minutes))
return parseUTCDate(isoDate).format('HH:mm')
}
}
}

View File

@ -4,7 +4,7 @@ import { Link, withRouter } from 'react-router-dom'
import Modal from './modal'
import * as api from '../../api'
import numberFormatter from '../../util/number-formatter'
import {parseQuery, toHuman} from '../../query'
import {parseQuery} from '../../query'
import RocketIcon from './rocket-icon'
class GoogleKeywordsModal extends React.Component {
@ -124,7 +124,6 @@ class GoogleKeywordsModal extends React.Component {
<main className="modal__content">
<h1 className="text-xl font-semibold mb-0 leading-none dark:text-gray-200">
{this.state.totalVisitors} visitors from Google<br />
{toHuman(this.state.query)}
</h1>
{this.renderGoalText()}
{ this.renderKeywords() }

View File

@ -4,7 +4,7 @@ import { Link, withRouter } from 'react-router-dom'
import Modal from './modal'
import * as api from '../../api'
import numberFormatter, {durationFormatter} from '../../util/number-formatter'
import {parseQuery, toHuman} from '../../query'
import {parseQuery} from '../../query'
class ReferrerDrilldownModal extends React.Component {
constructor(props) {
@ -98,14 +98,6 @@ class ReferrerDrilldownModal extends React.Component {
)
}
renderGoalText() {
if (this.state.query.filters.goal) {
return (
<h1 className="text-xl font-semibold text-gray-500 dark:text-gray-300 leading-none">completed {this.state.query.filters.goal}</h1>
)
}
}
renderBody() {
if (this.state.loading) {
return (
@ -118,9 +110,6 @@ class ReferrerDrilldownModal extends React.Component {
<div className="my-4 border-b border-gray-300 dark:border-gray-500"></div>
<main className="modal__content mt-0">
<h1 className="text-xl font-semibold mb-0 leading-none dark:text-gray-200">{this.state.totalVisitors} visitors from {decodeURIComponent(this.props.match.params.referrer)}<br /> {toHuman(this.state.query)}</h1>
{this.renderGoalText()}
<table className="w-max overflow-x-auto md:w-full table-striped table-fixed mt-4">
<thead>
<tr>

View File

@ -1,43 +1,27 @@
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
dayjs.extend(utc)
// https://stackoverflow.com/a/50130338
export function formatISO(date) {
return new Date(date.getTime() - (date.getTimezoneOffset() * 60000))
.toISOString()
.split("T")[0];
return date.format('YYYY-MM-DD')
}
export function shiftMonths(date, months) {
const newDate = new Date(date.getTime())
const d = newDate.getDate();
newDate.setMonth(newDate.getMonth() + +months);
if (newDate.getDate() != d) {
newDate.setDate(0);
}
return newDate;
return date.add(months, 'months')
}
export function shiftDays(date, days) {
const newDate = new Date(date.getTime())
newDate.setDate(newDate.getDate() + days)
return newDate
return date.add(days, 'days')
}
const MONTHS = [
"January", "February", "March",
"April", "May", "June", "July",
"August", "September", "October",
"November", "December"
]
const DAYS_ABBREV = [
"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"
]
export function formatMonthYYYY(date) {
return `${MONTHS[date.getMonth()]} ${date.getFullYear()}`;
return date.format('MMMM YYYY')
}
export function formatYear(date) {
return `Year of ${date.getFullYear()}`;
return `Year of ${date.year()}`;
}
export function formatYearShort(date) {
@ -45,41 +29,27 @@ export function formatYearShort(date) {
}
export function formatDay(date) {
var weekday = DAYS_ABBREV[date.getDay()];
if (date.getFullYear() !== (new Date()).getFullYear()) {
return `${weekday}, ${date.getDate()} ${formatMonthShort(date)} ${date.getFullYear()}`;
if (date.year() !== dayjs().year()) {
return date.format('ddd, DD MMM YYYY')
} else {
return `${weekday}, ${date.getDate()} ${formatMonthShort(date)}`;
return date.format('ddd, DD MMM')
}
}
export function formatDayShort(date, includeYear = false) {
let formatted = `${date.getDate()} ${formatMonthShort(date)}`
if (includeYear) {
formatted += ` ${formatYearShort(date)}`
}
return formatted
if (includeYear) {
return date.format('D MMM YY')
} else {
return date.format('D MMM')
}
}
export function parseUTCDate(dateString) {
var date;
// Safari Compatibility
if (typeof dateString === "string" && dateString.includes(' ')) {
const parts = dateString.split(/[^0-9]/);
parts[1] -= 1;
date = new Date(...parts);
} else {
date = new Date(dateString);
}
return new Date(date.getTime() + date.getTimezoneOffset() * 60000);
return dayjs.utc(dateString)
}
// https://stackoverflow.com/a/11124448
export function nowForSite(site) {
const browserOffset = (new Date()).getTimezoneOffset() * 60
return new Date(new Date().getTime() + (site.offset * 1000) + (browserOffset * 1000))
return dayjs.utc().utcOffset(site.offset / 60)
}
export function lastMonth(site) {
@ -99,43 +69,39 @@ export function isThisMonth(site, date) {
}
export function isThisYear(site, date) {
return date.getFullYear() === nowForSite(site).getFullYear()
return date.year() === nowForSite(site).year()
}
export function isBefore(date1, date2, period) {
/* assumes 'day' and 'month' are the only valid periods */
if (date1.getFullYear() !== date2.getFullYear()) {
return date1.getFullYear() < date2.getFullYear();
if (date1.year() !== date2.year()) {
return date1.year() < date2.year();
}
if (period === "year") {
return false;
}
if (date1.getMonth() !== date2.getMonth()) {
return date1.getMonth() < date2.getMonth();
if (date1.month() !== date2.month()) {
return date1.month() < date2.month();
}
if (period === "month") {
return false;
}
return date1.getDate() < date2.getDate()
return date1.date() < date2.date()
}
export function isAfter(date1, date2, period) {
/* assumes 'day' and 'month' are the only valid periods */
if (date1.getFullYear() !== date2.getFullYear()) {
return date1.getFullYear() > date2.getFullYear();
if (date1.year() !== date2.year()) {
return date1.year() > date2.year();
}
if (period === "year") {
return false;
}
if (date1.getMonth() !== date2.getMonth()) {
return date1.getMonth() > date2.getMonth();
if (date1.month() !== date2.month()) {
return date1.month() > date2.month();
}
if (period === "month") {
return false;
}
return date1.getDate() > date2.getDate()
}
function formatMonthShort(date) {
return `${MONTHS[date.getMonth()].substring(0, 3)}`;
return date1.date() > date2.date()
}

View File

@ -30,6 +30,7 @@
"css-loader": "^5.2.6",
"css-minimizer-webpack-plugin": "^3.2.0",
"datamaps": "^0.5.9",
"dayjs": "^1.11.7",
"debounce-promise": "^3.1.2",
"iframe-resizer": "^4.3.2",
"mini-css-extract-plugin": "^1.6.0",
@ -3536,6 +3537,11 @@
"topojson": "^1.6.19"
}
},
"node_modules/dayjs": {
"version": "1.11.7",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.7.tgz",
"integrity": "sha512-+Yw9U6YO5TQohxLcIkrXBeY73WP3ejHWVvx8XCk3gxvQDCTEmS48ZrSZCKciI7Bhl/uCMyxYtE9UqRILmFphkQ=="
},
"node_modules/debounce-promise": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/debounce-promise/-/debounce-promise-3.1.2.tgz",
@ -11319,6 +11325,11 @@
"topojson": "^1.6.19"
}
},
"dayjs": {
"version": "1.11.7",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.7.tgz",
"integrity": "sha512-+Yw9U6YO5TQohxLcIkrXBeY73WP3ejHWVvx8XCk3gxvQDCTEmS48ZrSZCKciI7Bhl/uCMyxYtE9UqRILmFphkQ=="
},
"debounce-promise": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/debounce-promise/-/debounce-promise-3.1.2.tgz",

View File

@ -32,6 +32,7 @@
"css-loader": "^5.2.6",
"css-minimizer-webpack-plugin": "^3.2.0",
"datamaps": "^0.5.9",
"dayjs": "^1.11.7",
"debounce-promise": "^3.1.2",
"iframe-resizer": "^4.3.2",
"mini-css-extract-plugin": "^1.6.0",