diff --git a/backend/app/services/counts_defaults.rb b/backend/app/services/counts_defaults.rb index db864e1..b5e35fc 100644 --- a/backend/app/services/counts_defaults.rb +++ b/backend/app/services/counts_defaults.rb @@ -25,7 +25,7 @@ class CountsDefaults elsif resource == 'tags' && token.start_with?('aliases.') [] elsif resource == 'posts' && token == 'tags.name' - ["tags.#{ name }", "tags.#{ aliases }"] + "tags.#{ name }" else token end end.join(' ') diff --git a/frontend/src/components/common/SortableList.tsx b/frontend/src/components/common/SortableList.tsx index eb86fde..f9ff4a8 100644 --- a/frontend/src/components/common/SortableList.tsx +++ b/frontend/src/components/common/SortableList.tsx @@ -25,9 +25,7 @@ const sameGroups = (left: Groups, right: Groups): boolean => const Group = ({ id, children }: { id: string; children: ReactNode }) => { const drop = useDroppable ({ id: `group:${id}` }) - return
{children}
+ return
{children}
} @@ -36,12 +34,13 @@ const SortableList = ( groups: Groups; name: (id: string) => Name handleName?: (id: string) => Name renderItem: (id: string, group: string) => ReactNode - renderGroup: (id: string, content: ReactNode) => ReactNode + renderGroup: (id: string, content: ReactNode, over: boolean) => ReactNode onChange: (groups: Groups) => void; fixed?: string[] }, ) => { const drag = useRef<{ id: string; groups: Groups } | null> (null) const scope = useId () const [active, setActive] = useState (null) + const [overGroup, setOverGroup] = useState (null) const motion = useClientMotion () const accessibleName = handleName ?? name const sensors = useSensors ( @@ -86,12 +85,15 @@ const SortableList = ( const reset = () => { drag.current = null setActive (null) + setOverGroup (null) } return { const hits = pointerWithin (args) - const available = hits.find (hit => hit.id === 'group:available') - return owner (String (args.active.id), groups) !== 'available' - && available != null ? [available] : closestCenter (args) + const group = hits.find (hit => String (hit.id).startsWith ('group:')) + const groupName = group?.id.toString ().slice (6) + const item = groupName != null && hits.some (hit => + owner (String (hit.id), groups) === groupName) + return group != null && !item ? [group] : closestCenter (args) }} accessibility={{ announcements: { onDragStart: event => accessibleName (String (event.active.id)).text, @@ -106,6 +108,12 @@ const SortableList = ( const id = String (event.active.id) drag.current = { id, groups } setActive (id) + setOverGroup (owner (id, groups) ?? null) + }} onDragOver={event => { + const id = event.over?.id.toString () + const group = id == null ? null + : (id.startsWith ('group:') ? id.slice (6) : owner (id, groups) ?? null) + setOverGroup (previous => previous === group ? previous : group) }} onDragCancel={reset} onDragEnd={event => { const session = drag.current reset () @@ -125,7 +133,7 @@ const SortableList = ( {renderItem (id, group)} )} - )} + , overGroup === group)} )} {typeof document !== 'undefined' && createPortal (
+ renderGroup={(id, content, over) =>
{label (id === 'available' ? 'axisItems' : id)} -
{content}
+
{content}
} renderItem={(id, group) => { const axis = axes.find (axis => axis.field === id) return
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) }