Add "Date" grouping option to reporting

This commit is contained in:
Andrew Herron
2026-07-30 10:49:45 +00:00
parent de13c07855
commit 2eb9ae699c
7 changed files with 159 additions and 34 deletions

View File

@@ -841,6 +841,50 @@ test('test that setting group by to current sub group triggers sub group fallbac
await expect(groupBySelects.filter({ hasText: 'Members' }).first()).toBeVisible(); await expect(groupBySelects.filter({ hasText: 'Members' }).first()).toBeVisible();
}); });
test('test that group by date groups the report by day and formats the date labels with organization settings', async ({
page,
ctx,
}) => {
await updateOrganizationSettingViaApi(ctx, { date_format: 'point-separated-d-m-yyyy' });
await createTimeEntryViaApi(ctx, {
description: 'Entry for group by date',
duration: '1h',
});
// Go to reporting page
await goToReporting(page);
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
// Find the "Group by" selects within the reporting table
const groupBySelects = page.locator('[data-testid="reporting_view"]').getByRole('combobox');
// Default state: group=Project
await groupBySelects.filter({ hasText: 'Project' }).first().click();
const [aggregateResponse] = await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') &&
response.url().includes('group=day') &&
response.status() === 200
),
page.getByRole('option', { name: 'Date', exact: true }).click(),
]);
// Verify the API request contains the correct group parameter
const requestUrl = new URL(aggregateResponse.url());
expect(requestUrl.searchParams.get('group')).toBe('day');
// The row label is rendered in the organization date format (D.M.YYYY)
await expect(
page.getByTestId('reporting_view').getByText(/^\d{1,2}\.\d{1,2}\.\d{4}$/)
).toBeVisible();
await expect(page.getByTestId('reporting_view').getByText(/^\d{4}-\d{2}-\d{2}$/)).toHaveCount(
0
);
});
// ────────────────────────────────────────────────── // ──────────────────────────────────────────────────
// Export Tests // Export Tests
// ────────────────────────────────────────────────── // ──────────────────────────────────────────────────

View File

@@ -16,6 +16,7 @@ import {
createTimeEntryWithBillableStatusViaApi, createTimeEntryWithBillableStatusViaApi,
createTagViaApi, createTagViaApi,
createReportViaApi, createReportViaApi,
updateOrganizationSettingViaApi,
} from './utils/api'; } from './utils/api';
import { import {
goToReporting, goToReporting,
@@ -68,6 +69,42 @@ test('test that saving a report creates a shared report and its shareable link s
await expect(page.getByText('Total')).toBeVisible(); await expect(page.getByText('Total')).toBeVisible();
}); });
test('test that a shared report grouped by date shows date labels formatted by the organization setting', async ({
page,
ctx,
}) => {
const reportName = 'DateGroupReport ' + Math.floor(Math.random() * 10000);
await updateOrganizationSettingViaApi(ctx, { date_format: 'point-separated-d-m-yyyy' });
await createTimeEntryViaApi(ctx, {
description: 'Entry for date grouping',
duration: '1h',
});
await goToReporting(page);
// Switch the grouping to "Date"
const groupBySelects = page.locator('[data-testid="reporting_view"]').getByRole('combobox');
await groupBySelects.filter({ hasText: 'Project' }).first().click();
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') &&
response.url().includes('group=day') &&
response.status() === 200
),
page.getByRole('option', { name: 'Date', exact: true }).click(),
]);
const { shareableLink } = await saveAsSharedReport(page, reportName);
// Verify row labels are formatted correctly
await page.goto(shareableLink);
await expect(page.getByText('Total')).toBeVisible();
await expect(page.getByText(/^\d{1,2}\.\d{1,2}\.\d{4}$/)).toBeVisible();
await expect(page.getByText(/^\d{4}-\d{2}-\d{2}$/)).toHaveCount(0);
});
test('test that shared report with invalid secret shows no data', async ({ page }) => { test('test that shared report with invalid secret shows no data', async ({ page }) => {
await page.goto(PLAYWRIGHT_BASE_URL + '/shared-report#invalid-secret-value'); await page.goto(PLAYWRIGHT_BASE_URL + '/shared-report#invalid-secret-value');
await expect(page.getByText('No time entries found').first()).toBeVisible(); await expect(page.getByText('No time entries found').first()).toBeVisible();

View File

@@ -240,7 +240,8 @@ const groupedPieChartData = computed(() => {
aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => { aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => {
const name = getNameForReportingRowEntry( const name = getNameForReportingRowEntry(
entry.key, entry.key,
aggregatedTableTimeEntries.value?.grouped_type ?? null aggregatedTableTimeEntries.value?.grouped_type ?? null,
organization?.value?.date_format
); );
let color = getRandomColorWithSeed(entry.key ?? 'none'); let color = getRandomColorWithSeed(entry.key ?? 'none');
if ( if (
@@ -255,11 +256,7 @@ const groupedPieChartData = computed(() => {
} }
return { return {
value: entry.seconds, value: entry.seconds,
name: name: name ?? '',
getNameForReportingRowEntry(
entry.key,
aggregatedTableTimeEntries.value?.grouped_type ?? null
) ?? '',
color: color, color: color,
}; };
}) ?? [] }) ?? []
@@ -269,18 +266,25 @@ const groupedPieChartData = computed(() => {
const tableData = computed(() => { const tableData = computed(() => {
return aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => { return aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => {
return { return {
key: entry.key,
seconds: entry.seconds, seconds: entry.seconds,
cost: entry.cost, cost: entry.cost,
description: getNameForReportingRowEntry( description: getNameForReportingRowEntry(
entry.key, entry.key,
aggregatedTableTimeEntries.value?.grouped_type ?? null aggregatedTableTimeEntries.value?.grouped_type ?? null,
organization?.value?.date_format
), ),
grouped_data: grouped_data:
entry.grouped_data?.map((el) => { entry.grouped_data?.map((el) => {
return { return {
key: el.key,
seconds: el.seconds, seconds: el.seconds,
cost: el.cost, cost: el.cost,
description: getNameForReportingRowEntry(el.key, entry.grouped_type), description: getNameForReportingRowEntry(
el.key,
entry.grouped_type,
organization?.value?.date_format
),
}; };
}) ?? [], }) ?? [],
}; };
@@ -421,9 +425,8 @@ const tableData = computed(() => {
"> ">
<ReportingRow <ReportingRow
v-for="entry in tableData" v-for="entry in tableData"
:key="entry.description ?? 'none'" :key="entry.key ?? 'none'"
:currency="getOrganizationCurrencyString()" :currency="getOrganizationCurrencyString()"
:type="aggregatedTableTimeEntries.grouped_type"
:show-cost="showBillableRate" :show-cost="showBillableRate"
:entry="entry"></ReportingRow> :entry="entry"></ReportingRow>
<div class="contents [&>*]:transition text-text-tertiary [&>*]:h-[50px]"> <div class="contents [&>*]:transition text-text-tertiary [&>*]:h-[50px]">

View File

@@ -11,6 +11,7 @@ type AggregatedGroupedData = GroupedData & {
}; };
type GroupedData = { type GroupedData = {
key: string | null;
seconds: number; seconds: number;
cost: number | null; cost: number | null;
description: string | null | undefined; description: string | null | undefined;
@@ -72,7 +73,7 @@ const organization = inject<ComputedRef<Organization>>('organization');
:style="`grid-template-columns: 1fr 150px ${showCost ? '150px' : ''}`"> :style="`grid-template-columns: 1fr 150px ${showCost ? '150px' : ''}`">
<ReportingRow <ReportingRow
v-for="subEntry in entry.grouped_data" v-for="subEntry in entry.grouped_data"
:key="subEntry.description ?? 'none'" :key="subEntry.key ?? 'none'"
:currency="props.currency" :currency="props.currency"
:show-cost="showCost" :show-cost="showCost"
indent indent

View File

@@ -95,20 +95,24 @@ const tableData = computed(() => {
return ( return (
aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => { aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => {
return { return {
key: entry.key,
seconds: entry.seconds, seconds: entry.seconds,
cost: entry.cost, cost: entry.cost,
description: getNameForReportingRowEntry( description: getNameForReportingRowEntry(
entry.key, entry.key,
aggregatedTableTimeEntries.value?.grouped_type ?? null aggregatedTableTimeEntries.value?.grouped_type ?? null,
organization?.value?.date_format
), ),
grouped_data: grouped_data:
entry.grouped_data?.map((el) => { entry.grouped_data?.map((el) => {
return { return {
key: el.key,
seconds: el.seconds, seconds: el.seconds,
cost: el.cost, cost: el.cost,
description: getNameForReportingRowEntry( description: getNameForReportingRowEntry(
el.key, el.key,
entry.grouped_type ?? null entry.grouped_type ?? null,
organization?.value?.date_format
), ),
}; };
}) ?? [], }) ?? [],
@@ -164,7 +168,7 @@ const showBillableRate = computed(() => {
"> ">
<ReportingRow <ReportingRow
v-for="entry in tableData" v-for="entry in tableData"
:key="entry.description ?? 'none'" :key="entry.key ?? 'none'"
:currency="getOrganizationCurrencyString()" :currency="getOrganizationCurrencyString()"
:show-cost="showBillableRate" :show-cost="showBillableRate"
:entry="entry"></ReportingRow> :entry="entry"></ReportingRow>

View File

@@ -116,25 +116,43 @@ const subGroup = computed(() => {
} }
return 'project'; return 'project';
}); });
const { emptyPlaceholder } = useReportingStore(); const { emptyPlaceholder, getNameForReportingRowEntry } = useReportingStore();
/**
* The public report endpoint has no descriptor for time group types, so their labels are
* derived from the raw group key.
*/
function resolveLabel(
description: string | null | undefined,
key: string | null | undefined,
groupedType: string
) {
if (description !== null && description !== undefined) {
return description;
}
return (
getNameForReportingRowEntry(key ?? null, groupedType, reportDateFormat.value) ??
emptyPlaceholder[groupedType] ??
''
);
}
const groupedPieChartData = computed(() => { const groupedPieChartData = computed(() => {
return ( return (
aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => { aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => {
if (entry.description === null) { const groupedType = aggregatedTableTimeEntries.value?.grouped_type ?? 'project';
const name = resolveLabel(entry.description, entry.key, groupedType);
if (name === emptyPlaceholder[groupedType]) {
return { return {
value: entry.seconds, value: entry.seconds,
name: name: name,
emptyPlaceholder[
aggregatedTableTimeEntries.value?.grouped_type ?? 'project'
] ?? '',
color: '#CCCCCC', color: '#CCCCCC',
}; };
} }
return { return {
value: entry.seconds, value: entry.seconds,
name: entry.description, name: name,
color: entry.color ?? getRandomColorWithSeed(entry.description ?? 'none'), color: entry.color ?? getRandomColorWithSeed(name),
}; };
}) ?? [] }) ?? []
); );
@@ -143,21 +161,25 @@ const groupedPieChartData = computed(() => {
const tableData = computed(() => { const tableData = computed(() => {
return aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => { return aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => {
return { return {
key: entry.key,
seconds: entry.seconds, seconds: entry.seconds,
cost: entry.cost, cost: entry.cost,
description: description: resolveLabel(
entry.description ?? entry.description,
emptyPlaceholder[aggregatedTableTimeEntries.value?.grouped_type ?? 'project'] ?? entry.key,
'', aggregatedTableTimeEntries.value?.grouped_type ?? 'project'
),
grouped_data: grouped_data:
entry.grouped_data?.map((el) => { entry.grouped_data?.map((el) => {
return { return {
key: el.key,
seconds: el.seconds, seconds: el.seconds,
cost: el.cost, cost: el.cost,
description: description: resolveLabel(
el.description ?? el.description,
emptyPlaceholder[entry.grouped_type ?? 'project'] ?? el.key,
'', entry.grouped_type ?? 'project'
),
}; };
}) ?? [], }) ?? [],
}; };
@@ -219,7 +241,7 @@ onMounted(async () => {
"> ">
<ReportingRow <ReportingRow
v-for="entry in tableData" v-for="entry in tableData"
:key="entry.description ?? 'none'" :key="entry.key ?? 'none'"
:currency="reportCurrency" :currency="reportCurrency"
:currency-format="reportCurrencyFormat" :currency-format="reportCurrencyFormat"
:show-cost="true" :show-cost="true"

View File

@@ -7,9 +7,10 @@ import { useTasksQuery } from '@/utils/useTasksQuery';
import { useClientsQuery } from '@/utils/useClientsQuery'; import { useClientsQuery } from '@/utils/useClientsQuery';
import { useTagsQuery } from '@/utils/useTagsQuery'; import { useTagsQuery } from '@/utils/useTagsQuery';
import { CheckCircleIcon, UserCircleIcon, UserGroupIcon } from '@heroicons/vue/20/solid'; import { CheckCircleIcon, UserCircleIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
import { DocumentTextIcon, FolderIcon } from '@heroicons/vue/16/solid'; import { CalendarIcon, DocumentTextIcon, FolderIcon } from '@heroicons/vue/16/solid';
import { Coffee } from '@lucide/vue'; import { Coffee } from '@lucide/vue';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue'; import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
import { type DateFormat, formatDate } from '@/packages/ui/src/utils/time';
export type GroupingOption = export type GroupingOption =
| 'project' | 'project'
@@ -19,7 +20,8 @@ export type GroupingOption =
| 'client' | 'client'
| 'description' | 'description'
| 'tag' | 'tag'
| 'type'; | 'type'
| 'day';
export const useReportingStore = defineStore('reporting', () => { export const useReportingStore = defineStore('reporting', () => {
// Cache query composables to avoid creating new subscriptions on every call // Cache query composables to avoid creating new subscriptions on every call
@@ -40,7 +42,11 @@ export const useReportingStore = defineStore('reporting', () => {
type: 'Work time', type: 'Work time',
} as Record<string, string>; } as Record<string, string>;
function getNameForReportingRowEntry(key: string | null, type: string | null) { function getNameForReportingRowEntry(
key: string | null,
type: string | null,
dateFormat?: DateFormat
) {
if (type === null) { if (type === null) {
return null; return null;
} }
@@ -76,6 +82,9 @@ export const useReportingStore = defineStore('reporting', () => {
if (type === 'type') { if (type === 'type') {
return key === 'break' ? 'Break' : 'Work time'; return key === 'break' ? 'Break' : 'Work time';
} }
if (type === 'day') {
return formatDate(key, dateFormat);
}
return key; return key;
} }
@@ -124,6 +133,11 @@ export const useReportingStore = defineStore('reporting', () => {
value: 'tag', value: 'tag',
icon: DocumentTextIcon, icon: DocumentTextIcon,
}, },
{
label: 'Date',
value: 'day',
icon: CalendarIcon,
},
]; ];
return { return {