Skip to content
Snippets Groups Projects
Unverified Commit 8993c554 authored by Tom Robinson's avatar Tom Robinson Committed by GitHub
Browse files

Merge pull request #9071 from metabase/translate-datetime-ui

Translate datetime UI
parents 6a7a3c3c 57cb5b9e
No related branches found
No related tags found
No related merge requests found
Showing
with 137 additions and 79 deletions
...@@ -513,7 +513,7 @@ export class DatetimeFieldDimension extends FieldDimension { ...@@ -513,7 +513,7 @@ export class DatetimeFieldDimension extends FieldDimension {
} }
subTriggerDisplayName(): string { subTriggerDisplayName(): string {
return "by " + formatBucketing(this._args[0]).toLowerCase(); return t`by ${formatBucketing(this._args[0]).toLowerCase()}`;
} }
render() { render() {
......
...@@ -11,7 +11,6 @@ import Icon from "metabase/components/Icon"; ...@@ -11,7 +11,6 @@ import Icon from "metabase/components/Icon";
export default class Calendar extends Component { export default class Calendar extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { this.state = {
current: moment(props.initial || undefined), current: moment(props.initial || undefined),
}; };
......
...@@ -25,8 +25,8 @@ const SPECIAL_STRINGS = new Set([ ...@@ -25,8 +25,8 @@ const SPECIAL_STRINGS = new Set([
"Max", "Max",
]); ]);
const obfuscateString = string => { const obfuscateString = (original, string) => {
if (SPECIAL_STRINGS.has(string)) { if (SPECIAL_STRINGS.has(original)) {
return string.toUpperCase(); return string.toUpperCase();
} else { } else {
// divide by 2 because Unicode `FULL BLOCK` is quite wide // divide by 2 because Unicode `FULL BLOCK` is quite wide
...@@ -40,10 +40,10 @@ export function enableTranslatedStringReplacement() { ...@@ -40,10 +40,10 @@ export function enableTranslatedStringReplacement() {
const _jt = c3po.jt; const _jt = c3po.jt;
const _ngettext = c3po.ngettext; const _ngettext = c3po.ngettext;
c3po.t = (...args) => { c3po.t = (...args) => {
return obfuscateString(_t(...args)); return obfuscateString(args[0][0], _t(...args));
}; };
c3po.ngettext = (...args) => { c3po.ngettext = (...args) => {
return obfuscateString(_ngettext(...args)); return obfuscateString(args[0][0], _ngettext(...args));
}; };
// eslint-disable-next-line react/display-name // eslint-disable-next-line react/display-name
c3po.jt = (...args) => { c3po.jt = (...args) => {
......
import { addLocale, useLocale } from "c-3po"; import { addLocale, useLocale } from "c-3po";
import moment from "moment";
// NOTE: loadLocalization not currently used, and we need to be sure to set the // NOTE: loadLocalization not currently used, and we need to be sure to set the
// initial localization before loading any files, so don't load metabase/services // initial localization before loading any files, so don't load metabase/services
...@@ -20,6 +21,8 @@ export function setLocalization(translationsObject) { ...@@ -20,6 +21,8 @@ export function setLocalization(translationsObject) {
// add and set locale with C-3PO // add and set locale with C-3PO
addLocale(locale, translationsObject); addLocale(locale, translationsObject);
useLocale(locale); useLocale(locale);
moment.locale(locale);
} }
// we delete msgid property since it's redundant, but have to add it back in to // we delete msgid property since it's redundant, but have to add it back in to
......
...@@ -3,6 +3,7 @@ import inflection from "inflection"; ...@@ -3,6 +3,7 @@ import inflection from "inflection";
import { formatDateTimeWithUnit } from "metabase/lib/formatting"; import { formatDateTimeWithUnit } from "metabase/lib/formatting";
import { parseTimestamp } from "metabase/lib/time"; import { parseTimestamp } from "metabase/lib/time";
import { t, ngettext, msgid } from "c-3po";
export const DATETIME_UNITS = [ export const DATETIME_UNITS = [
// "default", // "default",
...@@ -108,34 +109,38 @@ export function generateTimeIntervalDescription(n, unit) { ...@@ -108,34 +109,38 @@ export function generateTimeIntervalDescription(n, unit) {
switch (n) { switch (n) {
case "current": case "current":
case 0: case 0:
return ["Today"]; return [t`Today`];
case "next": case "next":
case 1: case 1:
return ["Tomorrow"]; return [t`Tomorrow`];
case "last": case "last":
case -1: case -1:
return ["Yesterday"]; return [t`Yesterday`];
} }
} }
if (!unit && n === 0) { if (!unit && n === 0) {
return "Today"; return t`Today`;
} // ['relative-datetime', 'current'] is a legal MBQL form but has no unit } // ['relative-datetime', 'current'] is a legal MBQL form but has no unit
unit = inflection.capitalize(unit); switch (n) {
if (typeof n === "string") { case "current":
if (n === "current") { case 0:
n = "this"; return [t`This ${formatBucketing(unit)}`];
} case "next":
return [inflection.capitalize(n) + " " + unit]; case 1:
return [t`Next ${formatBucketing(unit)}`];
case "last":
case -1:
return [t`Previous ${formatBucketing(unit)}`];
}
if (n < 0) {
return [t`Previous ${-n} ${formatBucketing(unit, -n)}`];
} else if (n > 0) {
return [t`Next ${n} ${formatBucketing(unit, n)}`];
} else { } else {
if (n < 0) { return [t`This ${formatBucketing(unit)}`];
return ["Past " + -n + " " + inflection.inflect(unit, -n)];
} else if (n > 0) {
return ["Next " + n + " " + inflection.inflect(unit, n)];
} else {
return ["This " + unit];
}
} }
} }
...@@ -163,23 +168,54 @@ export function generateTimeValueDescription(value, bucketing) { ...@@ -163,23 +168,54 @@ export function generateTimeValueDescription(value, bucketing) {
} else { } else {
// FIXME: what to do if the bucketing and unit don't match? // FIXME: what to do if the bucketing and unit don't match?
if (n === 0) { if (n === 0) {
return "Now"; return t`Now`;
} else { } else {
return ( return n < 0
Math.abs(n) + ? t`${-n} ${formatBucketing(unit, -n).toLowerCase()} ago`
" " + : t`${n} ${formatBucketing(unit, n).toLowerCase()} from now`;
inflection.inflect(unit, Math.abs(n)) +
(n < 0 ? " ago" : " from now")
);
} }
} }
} else { } else {
console.warn("Unknown datetime format", value); console.warn("Unknown datetime format", value);
return "[Unknown]"; return `[${t`Unknown`}]`;
} }
} }
export function formatBucketing(bucketing = "") { export function formatBucketing(bucketing = "", n = 1) {
switch (bucketing) {
case "default":
return ngettext(msgid`Default period`, `Default periods`, n);
case "minute":
return ngettext(msgid`Minute`, `Minutes`, n);
case "hour":
return ngettext(msgid`Hour`, `Hours`, n);
case "day":
return ngettext(msgid`Day`, `Days`, n);
case "week":
return ngettext(msgid`Week`, `Weeks`, n);
case "month":
return ngettext(msgid`Month`, `Months`, n);
case "quarter":
return ngettext(msgid`Quarter`, `Quarters`, n);
case "year":
return ngettext(msgid`Year`, `Years`, n);
case "minute-of-hour":
return ngettext(msgid`Minute of hour`, `Minutes of hour`, n);
case "hour-of-day":
return ngettext(msgid`Hour of day`, `Hours of day`, n);
case "day-of-week":
return ngettext(msgid`Day of week`, `Days of week`, n);
case "day-of-month":
return ngettext(msgid`Day of month`, `Days of month`, n);
case "day-of-year":
return ngettext(msgid`Day of year`, `Days of year`, n);
case "week-of-year":
return ngettext(msgid`Week of year`, `Weeks of year`, n);
case "month-of-year":
return ngettext(msgid`Month of year`, `Months of year`, n);
case "quarter-of-year":
return ngettext(msgid`Quarter of year`, `Quarters of year`, n);
}
let words = bucketing.split("-"); let words = bucketing.split("-");
words[0] = inflection.capitalize(words[0]); words[0] = inflection.capitalize(words[0]);
return words.join(" "); return words.join(" ");
...@@ -255,6 +291,7 @@ export function parseFieldTargetId(field) { ...@@ -255,6 +291,7 @@ export function parseFieldTargetId(field) {
function max() { function max() {
return moment(new Date(864000000000000)); return moment(new Date(864000000000000));
} }
function min() { function min() {
return moment(new Date(-864000000000000)); return moment(new Date(-864000000000000));
} }
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
import React, { Component } from "react"; import React, { Component } from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { t } from "c-3po"; import { t, ngettext, msgid } from "c-3po";
import { createMultiwordSearchRegex } from "metabase/lib/string"; import { createMultiwordSearchRegex } from "metabase/lib/string";
import { getHumanReadableValue } from "metabase/lib/query/field"; import { getHumanReadableValue } from "metabase/lib/query/field";
...@@ -58,7 +58,8 @@ export default class CategoryWidget extends Component { ...@@ -58,7 +58,8 @@ export default class CategoryWidget extends Component {
static format(values, fieldValues) { static format(values, fieldValues) {
if (Array.isArray(values) && values.length > 1) { if (Array.isArray(values) && values.length > 1) {
return `${values.length} selections`; const n = values.length;
return ngettext(msgid`${n} selection`, `${n} selections`, n);
} else { } else {
return getHumanReadableValue(values, fieldValues); return getHumanReadableValue(values, fieldValues);
} }
......
...@@ -5,6 +5,10 @@ import YearPicker from "./YearPicker.jsx"; ...@@ -5,6 +5,10 @@ import YearPicker from "./YearPicker.jsx";
import moment from "moment"; import moment from "moment";
import _ from "underscore"; import _ from "underscore";
import cx from "classnames"; import cx from "classnames";
import { t } from "c-3po";
// translator: this is a "moment" format string (https://momentjs.com/docs/#/displaying/format/) It should include "Q" for the quarter number, and raw text can be escaped by brackets. For eample "[Quarter] Q" will be rendered as "Quarter 1" etc
const QUARTER_FORMAT_STRING = t`[Q]Q`;
export default class DateQuarterYearWidget extends Component { export default class DateQuarterYearWidget extends Component {
constructor(props, context) { constructor(props, context) {
...@@ -84,6 +88,6 @@ const Quarter = ({ quarter, selected, onClick }) => ( ...@@ -84,6 +88,6 @@ const Quarter = ({ quarter, selected, onClick }) => (
> >
{moment() {moment()
.quarter(quarter) .quarter(quarter)
.format("[Q]Q")} .format(QUARTER_FORMAT_STRING)}
</li> </li>
); );
...@@ -20,12 +20,12 @@ const SHORTCUTS = [ ...@@ -20,12 +20,12 @@ const SHORTCUTS = [
]; ];
const RELATIVE_SHORTCUTS = { const RELATIVE_SHORTCUTS = {
Last: [ [t`Last`]: [
{ name: t`Week`, operator: "time-interval", values: ["last", "week"] }, { name: t`Week`, operator: "time-interval", values: ["last", "week"] },
{ name: t`Month`, operator: "time-interval", values: ["last", "month"] }, { name: t`Month`, operator: "time-interval", values: ["last", "month"] },
{ name: t`Year`, operator: "time-interval", values: ["last", "year"] }, { name: t`Year`, operator: "time-interval", values: ["last", "year"] },
], ],
This: [ [t`This`]: [
{ name: t`Week`, operator: "time-interval", values: ["current", "week"] }, { name: t`Week`, operator: "time-interval", values: ["current", "week"] },
{ name: t`Month`, operator: "time-interval", values: ["current", "month"] }, { name: t`Month`, operator: "time-interval", values: ["current", "month"] },
{ name: t`Year`, operator: "time-interval", values: ["current", "year"] }, { name: t`Year`, operator: "time-interval", values: ["current", "year"] },
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
import React, { Component } from "react"; import React, { Component } from "react";
import ReactDOM from "react-dom"; import ReactDOM from "react-dom";
import { t } from "c-3po"; import { t, ngettext, msgid } from "c-3po";
import FieldValuesWidget from "metabase/components/FieldValuesWidget"; import FieldValuesWidget from "metabase/components/FieldValuesWidget";
import Popover from "metabase/components/Popover"; import Popover from "metabase/components/Popover";
...@@ -54,7 +54,8 @@ export default class ParameterFieldWidget extends Component<*, Props, State> { ...@@ -54,7 +54,8 @@ export default class ParameterFieldWidget extends Component<*, Props, State> {
static format(value, field) { static format(value, field) {
value = normalizeValue(value); value = normalizeValue(value);
if (value.length > 1) { if (value.length > 1) {
return `${value.length} selections`; const n = value.length;
return ngettext(msgid`${n} selection`, `${n} selections`, n);
} else { } else {
return <RemappedValue value={value[0]} column={field} />; return <RemappedValue value={value[0]} column={field} />;
} }
......
...@@ -11,7 +11,7 @@ import { generateTimeFilterValuesDescriptions } from "metabase/lib/query_time"; ...@@ -11,7 +11,7 @@ import { generateTimeFilterValuesDescriptions } from "metabase/lib/query_time";
import { hasFilterOptions } from "metabase/lib/query/filter"; import { hasFilterOptions } from "metabase/lib/query/filter";
import { getFilterArgumentFormatOptions } from "metabase/lib/schema_metadata"; import { getFilterArgumentFormatOptions } from "metabase/lib/schema_metadata";
import { t } from "c-3po"; import { t, ngettext, msgid } from "c-3po";
import type { Filter as FilterT } from "metabase/meta/types/Query"; import type { Filter as FilterT } from "metabase/meta/types/Query";
import type { Value as ValueType } from "metabase/meta/types/Dataset"; import type { Value as ValueType } from "metabase/meta/types/Dataset";
...@@ -75,7 +75,8 @@ export const OperatorFilter = ({ ...@@ -75,7 +75,8 @@ export const OperatorFilter = ({
let formattedValues; let formattedValues;
// $FlowFixMe: not understanding maxDisplayValues is provided by defaultProps // $FlowFixMe: not understanding maxDisplayValues is provided by defaultProps
if (operator && operator.multi && values.length > maxDisplayValues) { if (operator && operator.multi && values.length > maxDisplayValues) {
formattedValues = [values.length + " selections"]; const n = values.length;
formattedValues = [ngettext(msgid`${n} selection`, `${n} selections`, n)];
} else if (dimension.field().isDate() && !dimension.field().isTime()) { } else if (dimension.field().isDate() && !dimension.field().isTime()) {
formattedValues = generateTimeFilterValuesDescriptions(filter); formattedValues = generateTimeFilterValuesDescriptions(filter);
} else { } else {
......
import React from "react"; import React from "react";
import Select, { Option } from "metabase/components/Select"; import Select, { Option } from "metabase/components/Select";
import { pluralize, capitalize } from "humanize-plus"; import { formatBucketing } from "metabase/lib/query_time";
type DateUnitSelectorProps = { type DateUnitSelectorProps = {
value: RelativeDatetimeUnit, value: RelativeDatetimeUnit,
...@@ -30,7 +30,7 @@ const DateUnitSelector = ({ ...@@ -30,7 +30,7 @@ const DateUnitSelector = ({
> >
{periods.map(period => ( {periods.map(period => (
<Option value={period} key={period}> <Option value={period} key={period}>
{capitalize(pluralize(formatter(intervals) || 1, period))} {formatBucketing(period, formatter(intervals) || 1)}
</Option> </Option>
))} ))}
</Select> </Select>
......
...@@ -4,6 +4,7 @@ import NumericInput from "metabase/components/NumericInput.jsx"; ...@@ -4,6 +4,7 @@ import NumericInput from "metabase/components/NumericInput.jsx";
import Icon from "metabase/components/Icon"; import Icon from "metabase/components/Icon";
import cx from "classnames"; import cx from "classnames";
import moment from "moment";
const HoursMinutesInput = ({ const HoursMinutesInput = ({
hours, hours,
...@@ -11,6 +12,7 @@ const HoursMinutesInput = ({ ...@@ -11,6 +12,7 @@ const HoursMinutesInput = ({
onChangeHours, onChangeHours,
onChangeMinutes, onChangeMinutes,
onClear, onClear,
is24HourMode = false,
}) => ( }) => (
<div className="flex align-center"> <div className="flex align-center">
<NumericInput <NumericInput
...@@ -18,8 +20,16 @@ const HoursMinutesInput = ({ ...@@ -18,8 +20,16 @@ const HoursMinutesInput = ({
style={{ height: 36 }} style={{ height: 36 }}
size={2} size={2}
maxLength={2} maxLength={2}
value={hours % 12 === 0 ? "12" : String(hours % 12)} value={
onChange={value => onChangeHours((hours >= 12 ? 12 : 0) + value)} is24HourMode
? String(hours)
: hours % 12 === 0 ? "12" : String(hours % 12)
}
onChange={
is24HourMode
? value => onChangeHours(value)
: value => onChangeHours((hours >= 12 ? 12 : 0) + value)
}
/> />
<span className="px1">:</span> <span className="px1">:</span>
<NumericInput <NumericInput
...@@ -30,26 +40,28 @@ const HoursMinutesInput = ({ ...@@ -30,26 +40,28 @@ const HoursMinutesInput = ({
value={(minutes < 10 ? "0" : "") + minutes} value={(minutes < 10 ? "0" : "") + minutes}
onChange={value => onChangeMinutes(value)} onChange={value => onChangeMinutes(value)}
/> />
<div className="flex align-center pl1"> {!is24HourMode && (
<span <div className="flex align-center pl1">
className={cx("text-purple-hover mr1", { <span
"text-purple": hours < 12, className={cx("text-purple-hover mr1", {
"cursor-pointer": hours >= 12, "text-purple": hours < 12,
})} "cursor-pointer": hours >= 12,
onClick={hours >= 12 ? () => onChangeHours(hours - 12) : null} })}
> onClick={hours >= 12 ? () => onChangeHours(hours - 12) : null}
AM >
</span> {moment.localeData().meridiem(0)}
<span </span>
className={cx("text-purple-hover mr1", { <span
"text-purple": hours >= 12, className={cx("text-purple-hover mr1", {
"cursor-pointer": hours < 12, "text-purple": hours >= 12,
})} "cursor-pointer": hours < 12,
onClick={hours < 12 ? () => onChangeHours(hours + 12) : null} })}
> onClick={hours < 12 ? () => onChangeHours(hours + 12) : null}
PM >
</span> {moment.localeData().meridiem(12)}
</div> </span>
</div>
)}
{onClear && ( {onClear && (
<Icon <Icon
className="text-light cursor-pointer text-medium-hover ml-auto" className="text-light cursor-pointer text-medium-hover ml-auto"
......
...@@ -144,7 +144,7 @@ export default class SpecificDatePicker extends Component { ...@@ -144,7 +144,7 @@ export default class SpecificDatePicker extends Component {
onClick={() => this.onChange(date, 12, 30)} onClick={() => this.onChange(date, 12, 30)}
> >
<Icon className="mr1" name="clock" /> <Icon className="mr1" name="clock" />
Add a time {t`Add a time`}
</div> </div>
) : ( ) : (
<HoursMinutesInput <HoursMinutesInput
......
...@@ -98,7 +98,7 @@ describe("query_time", () => { ...@@ -98,7 +98,7 @@ describe("query_time", () => {
-30, -30,
"day", "day",
]), ]),
).toEqual(["Past 30 Days"]); ).toEqual(["Previous 30 Days"]);
expect( expect(
generateTimeFilterValuesDescriptions([ generateTimeFilterValuesDescriptions([
"time-interval", "time-interval",
...@@ -106,7 +106,7 @@ describe("query_time", () => { ...@@ -106,7 +106,7 @@ describe("query_time", () => {
1, 1,
"month", "month",
]), ]),
).toEqual(["Next 1 Month"]); ).toEqual(["Next Month"]);
expect( expect(
generateTimeFilterValuesDescriptions([ generateTimeFilterValuesDescriptions([
"time-interval", "time-interval",
...@@ -130,7 +130,7 @@ describe("query_time", () => { ...@@ -130,7 +130,7 @@ describe("query_time", () => {
-1, -1,
"month", "month",
]), ]),
).toEqual(["Past 1 Month"]); ).toEqual(["Previous Month"]);
expect( expect(
generateTimeFilterValuesDescriptions([ generateTimeFilterValuesDescriptions([
"time-interval", "time-interval",
...@@ -138,7 +138,7 @@ describe("query_time", () => { ...@@ -138,7 +138,7 @@ describe("query_time", () => {
-2, -2,
"month", "month",
]), ]),
).toEqual(["Past 2 Months"]); ).toEqual(["Previous 2 Months"]);
}); });
it("should format 'time-interval' short names correctly", () => { it("should format 'time-interval' short names correctly", () => {
expect( expect(
......
...@@ -34,14 +34,14 @@ describe("TimeseriesFilterWidget", () => { ...@@ -34,14 +34,14 @@ describe("TimeseriesFilterWidget", () => {
const widget = mount(getTimeseriesFilterWidget(questionWithoutFilter)); const widget = mount(getTimeseriesFilterWidget(questionWithoutFilter));
expect(widget.find(".AdminSelect-content").text()).toBe("All Time"); expect(widget.find(".AdminSelect-content").text()).toBe("All Time");
}); });
it("should display 'Past 30 Days' text if that filter is selected", () => { it("should display 'Previous 30 Days' text if that filter is selected", () => {
const questionWithFilter = questionWithoutFilter const questionWithFilter = questionWithoutFilter
.query() .query()
.addFilter(["time-interval", ["field-id", 1], -30, "day"]) .addFilter(["time-interval", ["field-id", 1], -30, "day"])
.question(); .question();
const widget = mount(getTimeseriesFilterWidget(questionWithFilter)); const widget = mount(getTimeseriesFilterWidget(questionWithFilter));
expect(widget.find(".AdminSelect-content").text()).toBe("Past 30 Days"); expect(widget.find(".AdminSelect-content").text()).toBe("Previous 30 Days");
}); });
it("should display 'Is Empty' text if that filter is selected", () => { it("should display 'Is Empty' text if that filter is selected", () => {
const questionWithFilter = questionWithoutFilter const questionWithFilter = questionWithoutFilter
......
...@@ -121,6 +121,6 @@ describe("FieldList", () => { ...@@ -121,6 +121,6 @@ describe("FieldList", () => {
.last() .last()
.text(), .text(),
// eslint-disable-next-line no-irregular-whitespace // eslint-disable-next-line no-irregular-whitespace
).toMatch(/Created AtPast 300 Days/); ).toMatch(/Created AtPrevious 300 Days/);
}); });
}); });
...@@ -76,7 +76,7 @@ describe("SegmentPane", () => { ...@@ -76,7 +76,7 @@ describe("SegmentPane", () => {
.find(DataReference) .find(DataReference)
.find(QueryDefinition); .find(QueryDefinition);
// eslint-disable-next-line no-irregular-whitespace // eslint-disable-next-line no-irregular-whitespace
expect(queryDefinition.text()).toMatch(/Created AtPast 300 Days/); expect(queryDefinition.text()).toMatch(/Created AtPrevious 300 Days/);
}); });
it("lets you apply the filter to your current query", async () => { it("lets you apply the filter to your current query", async () => {
......
...@@ -235,19 +235,19 @@ ...@@ -235,19 +235,19 @@
(case unit (case unit
:day (date->interval-name parsed-timestamp :day (date->interval-name parsed-timestamp
(t/date-midnight (year) (month) (day)) (t/date-midnight (year) (month) (day))
(t/days 1) "Today" "Yesterday") (t/days 1) (tru "Today") (tru "Yesterday"))
:week (date->interval-name parsed-timestamp :week (date->interval-name parsed-timestamp
(start-of-this-week) (start-of-this-week)
(t/weeks 1) "This week" "Last week") (t/weeks 1) (tru "This week") (tru "Last week"))
:month (date->interval-name parsed-timestamp :month (date->interval-name parsed-timestamp
(t/date-midnight (year) (month)) (t/date-midnight (year) (month))
(t/months 1) "This month" "Last month") (t/months 1) (tru "This month") (tru "Last month"))
:quarter (date->interval-name parsed-timestamp :quarter (date->interval-name parsed-timestamp
(start-of-this-quarter) (start-of-this-quarter)
(t/months 3) "This quarter" "Last quarter") (t/months 3) (tru "This quarter") (tru "Last quarter"))
:year (date->interval-name (t/date-midnight parsed-timestamp) :year (date->interval-name (t/date-midnight parsed-timestamp)
(t/date-midnight (year)) (t/date-midnight (year))
(t/years 1) "This year" "Last year") (t/years 1) (tru "This year") (tru "Last year"))
nil))) nil)))
(defn- format-timestamp-pair (defn- format-timestamp-pair
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment