From 867d6eff188e134ecc4bc874adfd25452d4a2adb Mon Sep 17 00:00:00 2001 From: Gregor Vostrak Date: Tue, 21 May 2024 18:40:03 +0200 Subject: [PATCH] add table group chart, adapt to api changes --- e2e/timetracker.spec.ts | 1 - e2e/utils/currentTimeEntry.ts | 1 - openapi.json.client.ts | 79 ++++--- .../Common/Reporting/ReportingChart.vue | 17 +- .../Common/Reporting/ReportingPieChart.vue | 134 ++++++++++++ .../Common/Reporting/ReportingRow.vue | 50 +---- .../js/Components/CurrentSidebarTimer.vue | 4 +- resources/js/Layouts/AppLayout.vue | 2 +- resources/js/Pages/Reporting.vue | 42 ++-- resources/js/Pages/Teams/Show.vue | 12 +- resources/js/utils/color.ts | 8 + resources/js/utils/random.ts | 201 ++++++++++++++++++ resources/js/utils/time.ts | 6 + resources/js/utils/useReporting.ts | 49 ++++- resources/js/utils/useTimeEntries.ts | 2 +- 15 files changed, 506 insertions(+), 102 deletions(-) create mode 100644 resources/js/Components/Common/Reporting/ReportingPieChart.vue create mode 100644 resources/js/utils/random.ts diff --git a/e2e/timetracker.spec.ts b/e2e/timetracker.spec.ts index 4d294fb5..7b19b197 100644 --- a/e2e/timetracker.spec.ts +++ b/e2e/timetracker.spec.ts @@ -115,7 +115,6 @@ test('test that starting and updating the time while running works', async ({ (await response.json()).data.project_id === null && (await response.json()).data.description === '' && (await response.json()).data.task_id === null && - (await response.json()).data.duration === null && (await response.json()).data.user_id !== null && JSON.stringify((await response.json()).data.tags) === JSON.stringify([]) diff --git a/e2e/utils/currentTimeEntry.ts b/e2e/utils/currentTimeEntry.ts index f73faa68..5de2771b 100644 --- a/e2e/utils/currentTimeEntry.ts +++ b/e2e/utils/currentTimeEntry.ts @@ -27,7 +27,6 @@ export function newTimeEntryResponse( (await response.json()).data.project_id === null && (await response.json()).data.description === description && (await response.json()).data.task_id === null && - (await response.json()).data.duration === null && (await response.json()).data.user_id !== null && JSON.stringify((await response.json()).data.tags) === JSON.stringify(tags) diff --git a/openapi.json.client.ts b/openapi.json.client.ts index e6fafb4b..696268cd 100644 --- a/openapi.json.client.ts +++ b/openapi.json.client.ts @@ -118,7 +118,7 @@ const TaskResource = z const createTask_Body = z .object({ name: z.string(), project_id: z.string() }) .passthrough(); -const before = z.union([z.string(), z.null()]).optional(); +const start = z.union([z.string(), z.null()]).optional(); const TimeEntryResource = z .object({ id: z.string(), @@ -163,22 +163,6 @@ const v1_time_entries_update_multiple_Body = z .passthrough(), }) .passthrough(); -const group = z - .union([ - z.enum([ - 'day', - 'week', - 'month', - 'year', - 'user', - 'project', - 'task', - 'client', - 'billable', - ]), - z.null(), - ]) - .optional(); const updateTimeEntry_Body = z .object({ member_id: z.string().uuid().optional(), @@ -213,12 +197,11 @@ export const schemas = { TagCollection, TaskResource, createTask_Body, - before, + start, TimeEntryResource, TimeEntryCollection, createTimeEntry_Body, v1_time_entries_update_multiple_Body, - group, updateTimeEntry_Body, }; @@ -1776,14 +1759,14 @@ Users with the permission `time-entries:view:own` can only use this en schema: z.string().uuid().optional(), }, { - name: 'before', + name: 'start', type: 'Query', - schema: before, + schema: start, }, { - name: 'after', + name: 'end', type: 'Query', - schema: before, + schema: start, }, { name: 'active', @@ -2055,12 +2038,36 @@ If the group parameters are all set to `null` or are all missing, the { name: 'group', type: 'Query', - schema: group, + schema: z + .enum([ + 'day', + 'week', + 'month', + 'year', + 'user', + 'project', + 'task', + 'client', + 'billable', + ]) + .optional(), }, { name: 'sub_group', type: 'Query', - schema: group, + schema: z + .enum([ + 'day', + 'week', + 'month', + 'year', + 'user', + 'project', + 'task', + 'client', + 'billable', + ]) + .optional(), }, { name: 'member_id', @@ -2073,14 +2080,14 @@ If the group parameters are all set to `null` or are all missing, the schema: z.string().uuid().optional(), }, { - name: 'before', + name: 'start', type: 'Query', - schema: before, + schema: start, }, { - name: 'after', + name: 'end', type: 'Query', - schema: before, + schema: start, }, { name: 'active', @@ -2092,6 +2099,11 @@ If the group parameters are all set to `null` or are all missing, the type: 'Query', schema: z.enum(['true', 'false']).optional(), }, + { + name: 'fill_gaps_in_time_groups', + type: 'Query', + schema: z.enum(['true', 'false']).optional(), + }, { name: 'member_ids', type: 'Query', @@ -2117,19 +2129,22 @@ If the group parameters are all set to `null` or are all missing, the .object({ data: z .object({ + grouped_type: z.union([z.string(), z.null()]), grouped_data: z.union([ z.array( z .object({ - type: z.string(), key: z.union([z.string(), z.null()]), seconds: z.number().int(), cost: z.number().int(), + grouped_type: z.union([ + z.string(), + z.null(), + ]), grouped_data: z.union([ z.array( z .object({ - type: z.string(), key: z.union([ z.string(), z.null(), @@ -2138,6 +2153,8 @@ If the group parameters are all set to `null` or are all missing, the .number() .int(), cost: z.number().int(), + grouped_type: z.null(), + grouped_data: z.null(), }) .passthrough() ), diff --git a/resources/js/Components/Common/Reporting/ReportingChart.vue b/resources/js/Components/Common/Reporting/ReportingChart.vue index a8cb50eb..c171dc75 100644 --- a/resources/js/Components/Common/Reporting/ReportingChart.vue +++ b/resources/js/Components/Common/Reporting/ReportingChart.vue @@ -2,7 +2,11 @@ import VChart, { THEME_KEY } from 'vue-echarts'; import { computed, provide, ref } from 'vue'; import LinearGradient from 'zrender/lib/graphic/LinearGradient'; -import { formatHumanReadableDuration } from '@/utils/time'; +import { + formatDate, + formatHumanReadableDuration, + formatWeek, +} from '@/utils/time'; import { use } from 'echarts/core'; import { CanvasRenderer } from 'echarts/renderers'; import { BarChart } from 'echarts/charts'; @@ -30,10 +34,14 @@ type GroupedData = AggregatedTimeEntries['grouped_data']; const props = defineProps<{ groupedData: GroupedData; + groupedType: string | null; }>(); const xAxisLabels = computed(() => { - return props?.groupedData?.map((el) => el.key); + if (props.groupedType === 'week') { + return props?.groupedData?.map((el) => formatWeek(el.key)); + } + return props?.groupedData?.map((el) => formatDate(el.key ?? '')); }); const accentColor = useCssVar('--color-accent-quaternary'); @@ -108,9 +116,10 @@ const option = ref({ }, }, axisLabel: { - fontSize: 16, + fontSize: 12, fontWeight: 600, - margin: 24, + color: 'rgba(255,255,255,0.7)', + margin: 16, fontFamily: 'Outfit, sans-serif', }, axisTick: { diff --git a/resources/js/Components/Common/Reporting/ReportingPieChart.vue b/resources/js/Components/Common/Reporting/ReportingPieChart.vue new file mode 100644 index 00000000..0b40df4a --- /dev/null +++ b/resources/js/Components/Common/Reporting/ReportingPieChart.vue @@ -0,0 +1,134 @@ + + + + + diff --git a/resources/js/Components/Common/Reporting/ReportingRow.vue b/resources/js/Components/Common/Reporting/ReportingRow.vue index ab6cc3fa..38accff0 100644 --- a/resources/js/Components/Common/Reporting/ReportingRow.vue +++ b/resources/js/Components/Common/Reporting/ReportingRow.vue @@ -2,14 +2,13 @@ import { formatHumanReadableDuration } from '@/utils/time'; import { formatMoney } from '@/utils/money'; import GroupedItemsCountButton from '@/Components/Common/GroupedItemsCountButton.vue'; -import { computed, ref } from 'vue'; -import { useProjectsStore } from '@/utils/useProjects'; -import { storeToRefs } from 'pinia'; -import { useMembersStore } from '@/utils/useMembers'; -import { useTasksStore } from '@/utils/useTasks'; +import { ref } from 'vue'; import { twMerge } from 'tailwind-merge'; +import { useReportingStore } from '@/utils/useReporting'; +const { getNameForReportingRowEntry } = useReportingStore(); type AggregatedGroupedData = GroupedData & { + grouped_type?: string | null; grouped_data?: GroupedData[] | null; }; @@ -17,48 +16,16 @@ type GroupedData = { key: string | null; seconds: number; cost: number; - type: string; }; const props = defineProps<{ entry: AggregatedGroupedData; indent?: boolean; + type: string | null; }>(); -const emptyPlaceholder = computed(() => { - const emptyPlaceholder = { - user: 'No User', - project: 'No Project', - task: 'No Task', - billable: 'Non-Billable', - }; - - return emptyPlaceholder[props.entry.type as keyof typeof emptyPlaceholder]; -}); - -function getNameForKey(key: string) { - if (props.entry.type === 'project') { - const projectsStore = useProjectsStore(); - const { projects } = storeToRefs(projectsStore); - return projects.value.find((project) => project.id === key)?.name; - } - if (props.entry.type === 'user') { - const memberStore = useMembersStore(); - const { members } = storeToRefs(memberStore); - return members.value.find((member) => member.user_id === key)?.name; - } - if (props.entry.type === 'task') { - const taskStore = useTasksStore(); - const { tasks } = storeToRefs(taskStore); - return tasks.value.find((task) => task.id === key)?.name; - } - if (props.entry.type === 'billable') { - if (key === '0') { - return 'Non-Billable'; - } else { - return 'Billable'; - } - } +function getNameForKey(key: string | null) { + return getNameForReportingRowEntry(key, props.type); } const expanded = ref(false); @@ -80,7 +47,7 @@ const expanded = ref(false); {{ entry.grouped_data?.length }} - {{ entry.key ? getNameForKey(entry.key) : emptyPlaceholder }} + {{ getNameForKey(entry.key) }}
@@ -97,6 +64,7 @@ const expanded = ref(false);
diff --git a/resources/js/Components/CurrentSidebarTimer.vue b/resources/js/Components/CurrentSidebarTimer.vue index 296a4342..e6bf1fa7 100644 --- a/resources/js/Components/CurrentSidebarTimer.vue +++ b/resources/js/Components/CurrentSidebarTimer.vue @@ -30,7 +30,7 @@ const isRunningInDifferentOrganization = computed(() => { +
+ +
-
+
{ + :entry="entry" + :type=" + aggregatedTableTimeEntries.grouped_type + ">
Total
-
+
{{ formatHumanReadableDuration( aggregatedTableTimeEntries.seconds ) }}
-
+
{{ formatMoney( aggregatedTableTimeEntries.cost @@ -289,7 +299,13 @@ onMounted(() => {
-
+
+ +
diff --git a/resources/js/Pages/Teams/Show.vue b/resources/js/Pages/Teams/Show.vue index b4e16a94..eb5cc2ab 100644 --- a/resources/js/Pages/Teams/Show.vue +++ b/resources/js/Pages/Teams/Show.vue @@ -34,18 +34,18 @@ defineProps<{ :available-roles="availableRoles" :user-permissions="permissions" /> + + + + - - - -
diff --git a/resources/js/utils/color.ts b/resources/js/utils/color.ts index 2c9793be..73fd9f65 100644 --- a/resources/js/utils/color.ts +++ b/resources/js/utils/color.ts @@ -1,3 +1,5 @@ +import Prando from '@/utils/random'; + export const colors = [ '#ef5350', '#ec407a', @@ -23,3 +25,9 @@ export const colors = [ export function getRandomColor() { return colors[Math.floor(Math.random() * colors.length)]; } + +export function getRandomColorWithSeed(seed: string) { + const pseudoRandom = new Prando(seed); + const index = pseudoRandom.nextInt(0, colors.length - 1); + return colors[index]; +} diff --git a/resources/js/utils/random.ts b/resources/js/utils/random.ts new file mode 100644 index 00000000..6b7bebe6 --- /dev/null +++ b/resources/js/utils/random.ts @@ -0,0 +1,201 @@ +/** + * This is a hardfork of Prando, a pseudo-random number generator. + * @source https://github.com/zeh/prando + */ + +export default class Prando { + private static readonly MIN: number = -2147483648; // Int32 min + private static readonly MAX: number = 2147483647; // Int32 max + + private _seed: number; + private _value = NaN; + + // ================================================================================================================ + // CONSTRUCTOR ---------------------------------------------------------------------------------------------------- + + /** + * Generate a new Prando pseudo-random number generator. + * + * @param seed - A number or string seed that determines which pseudo-random number sequence will be created. Defaults to a random seed based on `Math.random()`. + */ + constructor(seed?: number | string) { + if (typeof seed === 'string') { + // String seed + this._seed = this.hashCode(seed); + } else if (typeof seed === 'number') { + // Numeric seed + this._seed = this.getSafeSeed(seed); + } else { + // Pseudo-random seed + this._seed = this.getSafeSeed( + Prando.MIN + + Math.floor((Prando.MAX - Prando.MIN) * Math.random()) + ); + } + this.reset(); + } + + // ================================================================================================================ + // PUBLIC INTERFACE ----------------------------------------------------------------------------------------------- + + /** + * Generates a pseudo-random number between a lower (inclusive) and a higher (exclusive) bounds. + * + * @param min - The minimum number that can be randomly generated. + * @param pseudoMax - The maximum number that can be randomly generated (exclusive). + * @return The generated pseudo-random number. + */ + public next(min = 0, pseudoMax = 1): number { + this.recalculate(); + return this.map(this._value, Prando.MIN, Prando.MAX, min, pseudoMax); + } + + /** + * Generates a pseudo-random integer number in a range (inclusive). + * + * @param min - The minimum number that can be randomly generated. + * @param max - The maximum number that can be randomly generated. + * @return The generated pseudo-random number. + */ + public nextInt(min = 10, max = 100): number { + this.recalculate(); + return Math.floor( + this.map(this._value, Prando.MIN, Prando.MAX, min, max + 1) + ); + } + + /** + * Generates a pseudo-random string sequence of a particular length from a specific character range. + * + * Note: keep in mind that creating a random string sequence does not guarantee uniqueness; there is always a + * 1 in (char_length^string_length) chance of collision. For real unique string ids, always check for + * pre-existing ids, or employ a robust GUID/UUID generator. + * + * @param length - Length of the string to be generated. + * @param chars - Characters that are used when creating the random string. Defaults to all alphanumeric chars (A-Z, a-z, 0-9). + * @return The generated string sequence. + */ + public nextString( + length = 16, + chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' + ): string { + let str = ''; + while (str.length < length) { + str += this.nextChar(chars); + } + return str; + } + + /** + * Generates a pseudo-random string of 1 character specific character range. + * + * @param chars - Characters that are used when creating the random string. Defaults to all alphanumeric chars (A-Z, a-z, 0-9). + * @return The generated character. + */ + public nextChar( + chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' + ): string { + return chars.substr(this.nextInt(0, chars.length - 1), 1); + } + + /** + * Picks a pseudo-random item from an array. The array is left unmodified. + * + * Note: keep in mind that while the returned item will be random enough, picking one item from the array at a time + * does not guarantee nor imply that a sequence of random non-repeating items will be picked. If you want to + * *pick items in a random order* from an array, instead of *pick one random item from an array*, it's best to + * apply a *shuffle* transformation to the array instead, then read it linearly. + * + * @param array - Array of any type containing one or more candidates for random picking. + * @return An item from the array. + */ + public nextArrayItem(array: T[]): T { + return array[this.nextInt(0, array.length - 1)]; + } + + /** + * Generates a pseudo-random boolean. + * + * @return A value of true or false. + */ + public nextBoolean(): boolean { + this.recalculate(); + return this._value > 0.5; + } + + /** + * Skips ahead in the sequence of numbers that are being generated. This is equivalent to + * calling next() a specified number of times, but faster since it doesn't need to map the + * new random numbers to a range and return it. + * + * @param iterations - The number of items to skip ahead. + */ + public skip(iterations = 1): void { + while (iterations-- > 0) { + this.recalculate(); + } + } + + /** + * Reset the pseudo-random number sequence back to its starting seed. Further calls to next() + * will then produce the same sequence of numbers it had produced before. This is equivalent to + * creating a new Prando instance with the same seed as another Prando instance. + * + * Example: + * let rng = new Prando(12345678); + * console.log(rng.next()); // 0.6177754114889017 + * console.log(rng.next()); // 0.5784605181725837 + * rng.reset(); + * console.log(rng.next()); // 0.6177754114889017 again + * console.log(rng.next()); // 0.5784605181725837 again + */ + public reset(): void { + this._value = this._seed; + } + + // ================================================================================================================ + // PRIVATE INTERFACE ---------------------------------------------------------------------------------------------- + + private recalculate(): void { + this._value = this.xorshift(this._value); + } + + private xorshift(value: number): number { + // Xorshift*32 + // Based on George Marsaglia's work: http://www.jstatsoft.org/v08/i14/paper + value ^= value << 13; + value ^= value >> 17; + value ^= value << 5; + return value; + } + + private map( + val: number, + minFrom: number, + maxFrom: number, + minTo: number, + maxTo: number + ): number { + return ( + ((val - minFrom) / (maxFrom - minFrom)) * (maxTo - minTo) + minTo + ); + } + + private hashCode(str: string): number { + let hash = 0; + if (str) { + const l = str.length; + for (let i = 0; i < l; i++) { + hash = (hash << 5) - hash + str.charCodeAt(i); + hash |= 0; + hash = this.xorshift(hash); + } + } + return this.getSafeSeed(hash); + } + + private getSafeSeed(seed: number): number { + if (seed === 0) return 1; + return seed; + } +} diff --git a/resources/js/utils/time.ts b/resources/js/utils/time.ts index ece1dfb3..e653ad93 100644 --- a/resources/js/utils/time.ts +++ b/resources/js/utils/time.ts @@ -5,6 +5,7 @@ import isToday from 'dayjs/plugin/isToday'; import isYesterday from 'dayjs/plugin/isYesterday'; import utc from 'dayjs/plugin/utc'; import timezone from 'dayjs/plugin/timezone'; +import weekOfYear from 'dayjs/plugin/weekOfYear'; import { getUserTimezone, getWeekStart } from '@/utils/useUser'; import updateLocale from 'dayjs/plugin/updateLocale'; import { computed } from 'vue'; @@ -16,6 +17,7 @@ dayjs.extend(duration); dayjs.extend(utc); dayjs.extend(timezone); dayjs.extend(updateLocale); +dayjs.extend(weekOfYear); export function getDayJsInstance() { dayjs.updateLocale('en', { @@ -75,6 +77,10 @@ export function formatDate(date: string): string { return dayjs(date).format('DD.MM.YYYY'); } +export function formatWeek(date: string | null): string { + return 'Week ' + dayjs(date).week(); +} + /* * Returns a human readable date format. * @param date - date in the format of 'YYYY-MM-DD' diff --git a/resources/js/utils/useReporting.ts b/resources/js/utils/useReporting.ts index 932ff986..854d7a37 100644 --- a/resources/js/utils/useReporting.ts +++ b/resources/js/utils/useReporting.ts @@ -1,4 +1,4 @@ -import { defineStore } from 'pinia'; +import { defineStore, storeToRefs } from 'pinia'; import { api } from '../../../openapi.json.client'; import { computed, ref } from 'vue'; import type { @@ -8,6 +8,9 @@ import type { } from '@/utils/api'; import { getCurrentOrganizationId } from '@/utils/useUser'; import { useNotificationsStore } from '@/utils/notification'; +import { useProjectsStore } from '@/utils/useProjects'; +import { useMembersStore } from '@/utils/useMembers'; +import { useTasksStore } from '@/utils/useTasks'; export const useReportingStore = defineStore('reporting', () => { const reportingGraphResponse = ref(null); @@ -59,10 +62,54 @@ export const useReportingStore = defineStore('reporting', () => { return reportingTableResponse.value?.data as AggregatedTimeEntries; }); + function getNameForReportingRowEntry( + key: string | null, + type: string | null + ) { + if (type === null) { + return null; + } + if (key === null) { + const emptyPlaceholder = { + user: 'No User', + project: 'No Project', + task: 'No Task', + billable: 'Non-Billable', + }; + + return emptyPlaceholder[type as keyof typeof emptyPlaceholder]; + } + + if (type === 'project') { + const projectsStore = useProjectsStore(); + const { projects } = storeToRefs(projectsStore); + return projects.value.find((project) => project.id === key)?.name; + } + if (type === 'user') { + const memberStore = useMembersStore(); + const { members } = storeToRefs(memberStore); + return members.value.find((member) => member.user_id === key)?.name; + } + if (type === 'task') { + const taskStore = useTasksStore(); + const { tasks } = storeToRefs(taskStore); + return tasks.value.find((task) => task.id === key)?.name; + } + if (type === 'billable') { + if (key === '0') { + return 'Non-Billable'; + } else { + return 'Billable'; + } + } + return key; + } + return { aggregatedGraphTimeEntries, fetchGraphReporting, fetchTableReporting, aggregatedTableTimeEntries, + getNameForReportingRowEntry, }; }); diff --git a/resources/js/utils/useTimeEntries.ts b/resources/js/utils/useTimeEntries.ts index eb4bad3d..0e3d8479 100644 --- a/resources/js/utils/useTimeEntries.ts +++ b/resources/js/utils/useTimeEntries.ts @@ -54,7 +54,7 @@ export const useTimeEntriesStore = defineStore('timeEntries', () => { queries: { only_full_dates: 'true', member_id: getCurrentMembershipId(), - before: dayjs(latestTimeEntry.start).utc().format(), + end: dayjs(latestTimeEntry.start).utc().format(), }, }), undefined,