const CountsSelectableTable = (
- { family, columns, identity, children, className, name, copyTable, stretched = false }: {
+ { family, columns, identity, children, className, name, copyTable }: {
family: string
columns: string[]
identity: string
children: ReactNode
className?: string
name?: { text: string; language: string }
- stretched?: boolean
copyTable?: () => CountsClipboardTable },
) => {
const id = useId ()
@@ -383,15 +382,13 @@ const CountsSelectableTable = (
}
}
const totalWidth = columns.reduce ((sum, key) => sum + layout.width (family, key), 0)
- const flexible = stretched ? columns.filter (key => !layout.custom (family, key)).length : 0
return (
0 ? '100%' : totalWidth }}
+ style={{ tableLayout: 'fixed', minWidth: totalWidth, width: totalWidth }}
className={cn ('counts-grid select-none border-separate border-spacing-0 outline-none',
'focus-visible:outline focus-visible:outline-2 focus-visible:outline-blue-500',
'dark:focus-visible:outline-blue-400',
@@ -618,10 +615,8 @@ const CountsSelectableTable = (
{label ('selection.hint')}
{textCell != null && label ('selection.textMode')}
- {columns.map (key => (
- 0 && !layout.custom (family, key)
- ? `calc(${layout.width (family, key)}px + (100% - ${totalWidth}px) / ${flexible})`
- : layout.width (family, key) }}/>))}
+ {columns.map (key => )}
{children}
{textCell != null && caret != null && caret.cell === textCell && createPortal (
diff --git a/frontend/src/components/counts/CountsSummaryResults.tsx b/frontend/src/components/counts/CountsSummaryResults.tsx
index 5f21f07..769431a 100644
--- a/frontend/src/components/counts/CountsSummaryResults.tsx
+++ b/frontend/src/components/counts/CountsSummaryResults.tsx
@@ -36,10 +36,11 @@ const CountsSummaryTable = (
const columnHeaders: CountsPivotHeader[] = data.columnHeaders
?? (data.columnValues ?? [[]]).map (values => ({ values, kind: 'data' }))
const sorts = readCountSort (params.get ('sort'))
+ const timezone = params.get ('timezone') ?? 'UTC'
const change = (key: string, additive: boolean) => update (
'sort', writeCountSort (changeCountSort (sorts, key, additive)))
const display = (value: string | number | null, axis: CountsAxis, index: number) =>
- countsSummaryValue (value, axis, index, data, locale)
+ countsSummaryValue (value, axis, index, data, locale, timezone)
const aggregateLabel = (header: CountsPivotHeader) => message (
header.labelKey ?? (header.kind === 'grand_total' ? (rows.length + cols.length === 1
? 'singleTotal' : 'grandTotal') : 'subtotal')).text
@@ -227,7 +228,6 @@ const CountsSummaryTable = (
}
return
key !== 'row_number').length > 1}
copyTable={() => ({ columns: keys.map ((_, index) => ({ label: headingRows[0][index] })),
headerRows: headingRows, rows: matrix })}
identity={`${countsRequestParams (params)}:${JSON.stringify (keys)}`}
@@ -295,7 +295,8 @@ const CountsSummaryResults = (props: Parameters[0]) =
const dragIds = Object.fromEntries (groups.map ((group, index) => [group, ids[index]]))
return ({
text: countsSummaryValue (JSON.parse (groups[ids.indexOf (id)])[0], columns[0],
- props.data.rows?.length ?? 0, props.data, locale), language: message ('count').language })}
+ props.data.rows?.length ?? 0, props.data, locale,
+ props.params.get ('timezone') ?? 'UTC'), language: message ('count').language })}
onChange={order => props.update ('column_order', encodeCountState ('column_order',
order.map (id => ({ value: JSON.parse (groups[ids.indexOf (id)])[0] }))))}>
{order => {
diff --git a/frontend/src/lib/countsDisplay.ts b/frontend/src/lib/countsDisplay.ts
index 1ce16a0..69b28e6 100644
--- a/frontend/src/lib/countsDisplay.ts
+++ b/frontend/src/lib/countsDisplay.ts
@@ -392,12 +392,66 @@ export const countsDetailClipboardTable = (
}
+const zonedCalendarDate = (
+ year: number, month: number, day: number, hour: number, timezone: string,
+): Date => {
+ const target = Date.UTC (year, month - 1, day, hour)
+ const formatter = new Intl.DateTimeFormat ('en-US-u-ca-gregory-nu-latn', {
+ timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit',
+ hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23' })
+ const align = (date: Date) => {
+ const parts = formatter.formatToParts (date)
+ const part = (type: string) => Number (
+ parts.find (entry => entry.type === type)?.value ?? 0)
+ const actual = Date.UTC (part ('year'), part ('month') - 1, part ('day'),
+ part ('hour'), part ('minute'), part ('second'))
+ return new Date (date.getTime () + target - actual)
+ }
+ return align (align (new Date (target)))
+}
+
+
+const countsDateValue = (
+ value: string, grain: string, locale: LocaleCode, timezone: string,
+): string | null => {
+ const patterns = {
+ year: /^(\d{4})$/, quarter: /^(\d{4})-Q([1-4])$/,
+ month: /^(\d{4})-(\d{2})$/, week: /^(\d{4})-W(\d{2})$/,
+ day: /^(\d{4})-(\d{2})-(\d{2})$/,
+ hour: /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):00$/ }
+ const pattern = patterns[grain as keyof typeof patterns]
+ const match = pattern == null ? null : value.match (pattern)
+ if (match == null) return null
+
+ const year = Number (match[1])
+ const weekStart = grain === 'week' ? new Date (Date.UTC (year, 0, 4)
+ - ((new Date (Date.UTC (year, 0, 4)).getUTCDay () + 6) % 7) * 86_400_000
+ + (Number (match[2]) - 1) * 7 * 86_400_000) : null
+ const calendarYear = weekStart?.getUTCFullYear () ?? year
+ const month = weekStart == null
+ ? (grain === 'quarter' ? (Number (match[2]) - 1) * 3 + 1
+ : (grain === 'month' || grain === 'day' || grain === 'hour'
+ ? Number (match[2]) : 1))
+ : weekStart.getUTCMonth () + 1
+ const day = weekStart?.getUTCDate ()
+ ?? (grain === 'day' || grain === 'hour' ? Number (match[3]) : 1)
+ const hour = grain === 'hour' ? Number (match[4]) : 12
+ const date = zonedCalendarDate (calendarYear, month, day, hour, timezone)
+ const unknown = ({ year: 'month', quarter: 'day', month: 'day', week: 'hour',
+ day: 'hour', hour: 'minute' } as const)[grain as keyof typeof patterns]
+ const formatted = localisedDateString (date, locale, unknown, timezone)
+ return grain === 'quarter' ? `${formatted} Q${match[2]}`
+ : (grain === 'week' ? `${formatted} W${match[2]}` : formatted)
+}
+
+
export const countsSummaryValue = (
value: string | number | null,
axis: CountsAxis,
index: number,
data: CountsResult,
locale: LocaleCode,
+ timezone = 'UTC',
): string => {
const message = (key: string, values?: Record) =>
translateUIMessage (key, 'counts', locale, values).text
@@ -414,5 +468,13 @@ export const countsSummaryValue = (
if (axis.field === 'event_type') return message (`events.${value}`)
if (axis.field === 'category' || axis.field === 'tag_category')
return message (`common:categories.${value}`)
+ const dateAxis = axis.field.endsWith ('_at')
+ || axis.field.startsWith ('original_created_')
+ const grain = axis.grain ?? (dateAxis ? 'month' : null)
+ if (grain != null)
+ {
+ const date = countsDateValue (String (value), grain, locale, timezone)
+ if (date != null) return date
+ }
return String (value)
}