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();
});
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
// ──────────────────────────────────────────────────

View File

@@ -16,6 +16,7 @@ import {
createTimeEntryWithBillableStatusViaApi,
createTagViaApi,
createReportViaApi,
updateOrganizationSettingViaApi,
} from './utils/api';
import {
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();
});
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 }) => {
await page.goto(PLAYWRIGHT_BASE_URL + '/shared-report#invalid-secret-value');
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) => {
const name = getNameForReportingRowEntry(
entry.key,
aggregatedTableTimeEntries.value?.grouped_type ?? null
aggregatedTableTimeEntries.value?.grouped_type ?? null,
organization?.value?.date_format
);
let color = getRandomColorWithSeed(entry.key ?? 'none');
if (
@@ -255,11 +256,7 @@ const groupedPieChartData = computed(() => {
}
return {
value: entry.seconds,
name:
getNameForReportingRowEntry(
entry.key,
aggregatedTableTimeEntries.value?.grouped_type ?? null
) ?? '',
name: name ?? '',
color: color,
};
}) ?? []
@@ -269,18 +266,25 @@ const groupedPieChartData = computed(() => {
const tableData = computed(() => {
return aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => {
return {
key: entry.key,
seconds: entry.seconds,
cost: entry.cost,
description: getNameForReportingRowEntry(
entry.key,
aggregatedTableTimeEntries.value?.grouped_type ?? null
aggregatedTableTimeEntries.value?.grouped_type ?? null,
organization?.value?.date_format
),
grouped_data:
entry.grouped_data?.map((el) => {
return {
key: el.key,
seconds: el.seconds,
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
v-for="entry in tableData"
:key="entry.description ?? 'none'"
:key="entry.key ?? 'none'"
:currency="getOrganizationCurrencyString()"
:type="aggregatedTableTimeEntries.grouped_type"
:show-cost="showBillableRate"
:entry="entry"></ReportingRow>
<div class="contents [&>*]:transition text-text-tertiary [&>*]:h-[50px]">

View File

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

View File

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

View File

@@ -116,25 +116,43 @@ const subGroup = computed(() => {
}
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(() => {
return (
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 {
value: entry.seconds,
name:
emptyPlaceholder[
aggregatedTableTimeEntries.value?.grouped_type ?? 'project'
] ?? '',
name: name,
color: '#CCCCCC',
};
}
return {
value: entry.seconds,
name: entry.description,
color: entry.color ?? getRandomColorWithSeed(entry.description ?? 'none'),
name: name,
color: entry.color ?? getRandomColorWithSeed(name),
};
}) ?? []
);
@@ -143,21 +161,25 @@ const groupedPieChartData = computed(() => {
const tableData = computed(() => {
return aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => {
return {
key: entry.key,
seconds: entry.seconds,
cost: entry.cost,
description:
entry.description ??
emptyPlaceholder[aggregatedTableTimeEntries.value?.grouped_type ?? 'project'] ??
'',
description: resolveLabel(
entry.description,
entry.key,
aggregatedTableTimeEntries.value?.grouped_type ?? 'project'
),
grouped_data:
entry.grouped_data?.map((el) => {
return {
key: el.key,
seconds: el.seconds,
cost: el.cost,
description:
el.description ??
emptyPlaceholder[entry.grouped_type ?? 'project'] ??
'',
description: resolveLabel(
el.description,
el.key,
entry.grouped_type ?? 'project'
),
};
}) ?? [],
};
@@ -219,7 +241,7 @@ onMounted(async () => {
">
<ReportingRow
v-for="entry in tableData"
:key="entry.description ?? 'none'"
:key="entry.key ?? 'none'"
:currency="reportCurrency"
:currency-format="reportCurrencyFormat"
:show-cost="true"

View File

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