mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-06-06 20:37:32 +00:00
215 lines
7.3 KiB
Vue
215 lines
7.3 KiB
Vue
<script lang="ts" setup>
|
|
import VChart, { THEME_KEY } from 'vue-echarts';
|
|
import { provide, computed, inject, ref, type ComputedRef } from 'vue';
|
|
import { use } from 'echarts/core';
|
|
import { useElementSize } from '@vueuse/core';
|
|
import DashboardCard from '@/Components/Dashboard/DashboardCard.vue';
|
|
import { BoltIcon } from '@heroicons/vue/20/solid';
|
|
import { HeatmapChart } from 'echarts/charts';
|
|
import {
|
|
CalendarComponent,
|
|
TitleComponent,
|
|
TooltipComponent,
|
|
VisualMapComponent,
|
|
} from 'echarts/components';
|
|
import { CanvasRenderer } from 'echarts/renderers';
|
|
import {
|
|
firstDayIndex,
|
|
formatDate,
|
|
formatHumanReadableDuration,
|
|
getDayJsInstance,
|
|
} from '@/packages/ui/src/utils/time';
|
|
import chroma from 'chroma-js';
|
|
import { useCssVariable } from '@/packages/ui/src';
|
|
import { useQuery } from '@tanstack/vue-query';
|
|
import { getCurrentOrganizationId } from '@/utils/useUser';
|
|
import { api, type Organization } from '@/packages/api/src';
|
|
import { LoadingSpinner } from '@/packages/ui/src';
|
|
|
|
const organization = inject<ComputedRef<Organization>>('organization');
|
|
|
|
// Get the organization ID using the utility function
|
|
const organizationId = computed(() => getCurrentOrganizationId());
|
|
|
|
const { data: dailyHoursTracked, isLoading } = useQuery({
|
|
queryKey: ['dailyTrackedHours', organizationId],
|
|
queryFn: () => {
|
|
return api.dailyTrackedHours({
|
|
params: {
|
|
organization: organizationId.value!,
|
|
},
|
|
});
|
|
},
|
|
enabled: computed(() => !!organizationId.value),
|
|
});
|
|
|
|
use([
|
|
TitleComponent,
|
|
TooltipComponent,
|
|
VisualMapComponent,
|
|
CalendarComponent,
|
|
HeatmapChart,
|
|
CanvasRenderer,
|
|
]);
|
|
|
|
provide(THEME_KEY, 'dark');
|
|
|
|
const max = computed(() => {
|
|
if (!isLoading.value && dailyHoursTracked.value) {
|
|
return Math.max(Math.max(...dailyHoursTracked.value.map((el) => el.duration)), 1);
|
|
} else {
|
|
return 1;
|
|
}
|
|
});
|
|
|
|
const backgroundColor = useCssVariable('--theme-color-card-background');
|
|
const borderColor = useCssVariable('--color-border');
|
|
const labelColor = useCssVariable('--color-text-secondary');
|
|
const chartColorRaw = useCssVariable('--theme-color-chart');
|
|
|
|
const chartEmptyColorRaw = useCssVariable('--color-bg-tertiary');
|
|
const chartEmptyColor = computed(() => {
|
|
if (!chartEmptyColorRaw.value) return '#2a2c32';
|
|
return chroma(chartEmptyColorRaw.value).hex();
|
|
});
|
|
const chartColor = computed(() => {
|
|
if (!chartColorRaw.value) return '#bae6fd';
|
|
return `rgb(${chartColorRaw.value})`;
|
|
});
|
|
|
|
// Track chart container size
|
|
const chartContainer = ref<HTMLElement | null>(null);
|
|
const { width: containerWidth } = useElementSize(chartContainer);
|
|
|
|
// Calculate number of weeks based on available width
|
|
// Rough estimate: 40px per cell + 80px for labels = ~360px for 7 weeks
|
|
const numberOfWeeks = computed(() => {
|
|
const availableWidth = containerWidth.value || 400;
|
|
const minCellSize = 25; // Minimum cell size in pixels
|
|
const labelSpace = 80; // Space for day labels
|
|
const usableWidth = availableWidth - labelSpace;
|
|
const maxWeeks = Math.floor(usableWidth / minCellSize);
|
|
// Clamp between 4 and 12 weeks for reasonable display
|
|
return Math.max(4, Math.min(12, maxWeeks));
|
|
});
|
|
|
|
// Calculate date range based on dynamic number of weeks
|
|
const dateRange = computed(() => {
|
|
const today = getDayJsInstance()();
|
|
const startOfWeek = today.startOf('week');
|
|
// Go back (numberOfWeeks - 1) weeks from the start of current week
|
|
const rangeStart = startOfWeek.subtract(numberOfWeeks.value - 1, 'week');
|
|
return [today.format('YYYY-MM-DD'), rangeStart.format('YYYY-MM-DD')];
|
|
});
|
|
|
|
const option = computed(() => {
|
|
return {
|
|
tooltip: {},
|
|
visualMap: {
|
|
type: 'piecewise',
|
|
orient: 'horizontal',
|
|
left: 'center',
|
|
top: 'center',
|
|
pieces: [
|
|
{ value: 0, color: chartEmptyColor.value },
|
|
{
|
|
gt: 0,
|
|
lte: max.value * 0.25,
|
|
color: chroma.mix(chartEmptyColor.value, chartColor.value, 0.3).hex(),
|
|
},
|
|
{
|
|
gt: max.value * 0.25,
|
|
lte: max.value * 0.5,
|
|
color: chroma.mix(chartEmptyColor.value, chartColor.value, 0.6).hex(),
|
|
},
|
|
{
|
|
gt: max.value * 0.5,
|
|
lte: max.value * 0.75,
|
|
color: chroma.mix(chartEmptyColor.value, chartColor.value, 0.8).hex(),
|
|
},
|
|
{ gt: max.value * 0.75, lte: max.value, color: chartColor.value },
|
|
],
|
|
show: false,
|
|
},
|
|
calendar: {
|
|
top: 35,
|
|
bottom: 20,
|
|
left: 35,
|
|
right: 5,
|
|
cellSize: 'auto',
|
|
orient: 'horizontal',
|
|
dayLabel: {
|
|
firstDay: firstDayIndex.value,
|
|
color: labelColor.value,
|
|
fontFamily: 'Inter, sans-serif',
|
|
},
|
|
monthLabel: {
|
|
color: labelColor.value,
|
|
fontFamily: 'Inter, sans-serif',
|
|
},
|
|
splitLine: {
|
|
show: false,
|
|
},
|
|
range: dateRange.value,
|
|
itemStyle: {
|
|
color: 'transparent',
|
|
borderWidth: 8,
|
|
borderColor: backgroundColor.value,
|
|
},
|
|
yearLabel: { show: false },
|
|
},
|
|
series: {
|
|
type: 'heatmap',
|
|
coordinateSystem: 'calendar',
|
|
data: dailyHoursTracked?.value?.map((el) => [el.date, el.duration]) ?? [],
|
|
itemStyle: {
|
|
borderRadius: 5,
|
|
borderColor: borderColor.value,
|
|
borderWidth: 1,
|
|
},
|
|
tooltip: {
|
|
valueFormatter: (value: number, dataIndex: number) => {
|
|
if (dailyHoursTracked?.value) {
|
|
return (
|
|
formatDate(
|
|
dailyHoursTracked?.value[dataIndex]?.date ?? '',
|
|
organization?.value?.date_format
|
|
) +
|
|
': ' +
|
|
formatHumanReadableDuration(
|
|
value,
|
|
organization?.value?.interval_format,
|
|
organization?.value?.number_format
|
|
)
|
|
);
|
|
} else {
|
|
return '';
|
|
}
|
|
},
|
|
},
|
|
},
|
|
backgroundColor: 'transparent',
|
|
};
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<DashboardCard title="Activity Graph" :icon="BoltIcon">
|
|
<div class="px-2">
|
|
<div v-if="isLoading" class="flex justify-center items-center h-40">
|
|
<LoadingSpinner />
|
|
</div>
|
|
<div v-else-if="dailyHoursTracked" ref="chartContainer">
|
|
<v-chart
|
|
class="chart"
|
|
:autoresize="true"
|
|
:option="option"
|
|
style="height: 260px; background-color: transparent" />
|
|
</div>
|
|
<div v-else class="text-center text-gray-500 py-8">No activity data available</div>
|
|
</div>
|
|
</DashboardCard>
|
|
</template>
|
|
|
|
<style></style>
|