mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-15 11:42:15 +01:00
add activity status plugin to calendar
This commit is contained in:
@@ -23,7 +23,7 @@ const dateFormat = computed(() => organization?.value?.date_format);
|
|||||||
<div class="text-xs text-muted-foreground font-medium">
|
<div class="text-xs text-muted-foreground font-medium">
|
||||||
{{ date.format('ddd') }}
|
{{ date.format('ddd') }}
|
||||||
</div>
|
</div>
|
||||||
<span>{{ formatDate(date.toISOString(), dateFormat) }}</span>
|
<span class="text-xs">{{ formatDate(date.toISOString(), dateFormat) }}</span>
|
||||||
<span class="block text-xs text-muted-foreground font-medium mt-1">
|
<span class="block text-xs text-muted-foreground font-medium mt-1">
|
||||||
{{ formatHumanReadableDuration(totalSeconds, intervalFormat, numberFormat) }}
|
{{ formatHumanReadableDuration(totalSeconds, intervalFormat, numberFormat) }}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -40,18 +40,18 @@ const formattedDuration = computed(() =>
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="text-xs leading-tight">
|
<div class="text-2xs leading-tight px-0.5 py-1.5">
|
||||||
<div class="font-semibold mb-0.5">{{ title }}</div>
|
<div class="font-semibold">{{ title }}</div>
|
||||||
<div v-if="projectName" class="font-medium text-[0.6875rem] opacity-90">
|
<div v-if="projectName" class="font-medium opacity-90">
|
||||||
{{ projectName }}
|
{{ projectName }}
|
||||||
</div>
|
</div>
|
||||||
<div v-if="taskName" class="font-medium text-[0.6875rem] opacity-90">
|
<div v-if="taskName" class="font-medium">
|
||||||
{{ taskName }}
|
{{ taskName }}
|
||||||
</div>
|
</div>
|
||||||
<div v-if="clientName" class="text-[0.625rem] italic opacity-85">
|
<div v-if="clientName" class="opacity-85">
|
||||||
{{ clientName }}
|
{{ clientName }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-[0.625rem] font-semibold opacity-90 mt-0.5">
|
<div class="opacity-90">
|
||||||
{{ formattedDuration }}
|
{{ formattedDuration }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,16 @@ import dayGridPlugin from '@fullcalendar/daygrid';
|
|||||||
import timeGridPlugin from '@fullcalendar/timegrid';
|
import timeGridPlugin from '@fullcalendar/timegrid';
|
||||||
import interactionPlugin from '@fullcalendar/interaction';
|
import interactionPlugin from '@fullcalendar/interaction';
|
||||||
import type { DatesSetArg, EventClickArg, EventDropArg, EventChangeArg } from '@fullcalendar/core';
|
import type { DatesSetArg, EventClickArg, EventDropArg, EventChangeArg } from '@fullcalendar/core';
|
||||||
import { computed, ref, watch, inject, type ComputedRef } from 'vue';
|
import {
|
||||||
|
computed,
|
||||||
|
ref,
|
||||||
|
watch,
|
||||||
|
inject,
|
||||||
|
type ComputedRef,
|
||||||
|
nextTick,
|
||||||
|
onMounted,
|
||||||
|
onActivated,
|
||||||
|
} from 'vue';
|
||||||
import chroma from 'chroma-js';
|
import chroma from 'chroma-js';
|
||||||
import { useCssVariable } from '@/utils/useCssVariable';
|
import { useCssVariable } from '@/utils/useCssVariable';
|
||||||
import { getDayJsInstance, getLocalizedDayJs } from '../utils/time';
|
import { getDayJsInstance, getLocalizedDayJs } from '../utils/time';
|
||||||
@@ -12,6 +21,10 @@ import { getUserTimezone, getWeekStart } from '../utils/settings';
|
|||||||
import { LoadingSpinner, TimeEntryCreateModal, TimeEntryEditModal } from '..';
|
import { LoadingSpinner, TimeEntryCreateModal, TimeEntryEditModal } from '..';
|
||||||
import FullCalendarEventContent from './FullCalendarEventContent.vue';
|
import FullCalendarEventContent from './FullCalendarEventContent.vue';
|
||||||
import FullCalendarDayHeader from './FullCalendarDayHeader.vue';
|
import FullCalendarDayHeader from './FullCalendarDayHeader.vue';
|
||||||
|
import activityStatusPlugin, {
|
||||||
|
type ActivityPeriod,
|
||||||
|
renderActivityStatusBoxes,
|
||||||
|
} from './idleStatusPlugin';
|
||||||
import type {
|
import type {
|
||||||
TimeEntry,
|
TimeEntry,
|
||||||
Project,
|
Project,
|
||||||
@@ -37,6 +50,7 @@ const props = defineProps<{
|
|||||||
tasks: Task[];
|
tasks: Task[];
|
||||||
clients: Client[];
|
clients: Client[];
|
||||||
tags: Tag[];
|
tags: Tag[];
|
||||||
|
activityPeriods?: ActivityPeriod[];
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
|
||||||
// Permissions / feature flags
|
// Permissions / feature flags
|
||||||
@@ -165,6 +179,8 @@ const dailyTotals = computed(() => {
|
|||||||
|
|
||||||
function emitDatesChange(arg: DatesSetArg) {
|
function emitDatesChange(arg: DatesSetArg) {
|
||||||
emit('dates-change', { start: arg.start, end: arg.end });
|
emit('dates-change', { start: arg.start, end: arg.end });
|
||||||
|
// Render activity boxes after calendar view has been rendered
|
||||||
|
renderActivityBoxes();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDateSelect(arg: { start: Date; end: Date }) {
|
function handleDateSelect(arg: { start: Date; end: Date }) {
|
||||||
@@ -234,7 +250,7 @@ async function handleEventResize(arg: EventChangeArg) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const calendarOptions = computed(() => ({
|
const calendarOptions = computed(() => ({
|
||||||
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin],
|
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin, activityStatusPlugin],
|
||||||
initialView: 'timeGridWeek',
|
initialView: 'timeGridWeek',
|
||||||
headerToolbar: {
|
headerToolbar: {
|
||||||
left: 'prev,next today',
|
left: 'prev,next today',
|
||||||
@@ -265,6 +281,7 @@ const calendarOptions = computed(() => ({
|
|||||||
datesSet: emitDatesChange,
|
datesSet: emitDatesChange,
|
||||||
|
|
||||||
events: events.value,
|
events: events.value,
|
||||||
|
activityPeriods: props.activityPeriods || [],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
watch(showCreateTimeEntryModal, (value) => {
|
watch(showCreateTimeEntryModal, (value) => {
|
||||||
@@ -283,6 +300,48 @@ watch(showEditTimeEntryModal, (value) => {
|
|||||||
emit('refresh');
|
emit('refresh');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Render activity status boxes after FullCalendar has rendered
|
||||||
|
const renderActivityBoxes = () => {
|
||||||
|
if (!calendarRef.value || !props.activityPeriods) return;
|
||||||
|
|
||||||
|
const calendarEl = calendarRef.value.$el as HTMLElement;
|
||||||
|
if (calendarEl && props.activityPeriods.length > 0) {
|
||||||
|
renderActivityStatusBoxes(calendarEl, props.activityPeriods);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Watch for activity periods changes - re-render when data changes
|
||||||
|
watch(
|
||||||
|
() => props.activityPeriods,
|
||||||
|
() => {
|
||||||
|
renderActivityBoxes();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const scrollToCurrentTime = () => {
|
||||||
|
nextTick(() => {
|
||||||
|
if (calendarRef.value) {
|
||||||
|
const now = getDayJsInstance()();
|
||||||
|
const oneHourBefore = now.subtract(1, 'hour');
|
||||||
|
|
||||||
|
// If subtracting 1 hour keeps us on the same day, scroll to 1 hour before
|
||||||
|
const scrollTime = now.isSame(oneHourBefore, 'day')
|
||||||
|
? oneHourBefore.format('HH:mm:ss')
|
||||||
|
: now.format('HH:mm:ss');
|
||||||
|
|
||||||
|
calendarRef.value.getApi().scrollToTime(scrollTime);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
scrollToCurrentTime();
|
||||||
|
});
|
||||||
|
|
||||||
|
onActivated(() => {
|
||||||
|
scrollToCurrentTime();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -377,11 +436,11 @@ watch(showEditTimeEntryModal, (value) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-timegrid-slot-label) {
|
.fullcalendar :deep(.fc-timegrid-slot-label) {
|
||||||
background-color: var(--theme-color-default-background);
|
background-color: var(--background);
|
||||||
}
|
}
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-toolbar) {
|
.fullcalendar :deep(.fc-toolbar) {
|
||||||
background-color: var(--theme-color-default-background);
|
background-color: var(--background);
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
@@ -463,7 +522,7 @@ watch(showEditTimeEntryModal, (value) => {
|
|||||||
|
|
||||||
.fullcalendar :deep(.fc-event) {
|
.fullcalendar :deep(.fc-event) {
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
padding: 0.45rem 0.25rem;
|
padding: 0;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
box-shadow: var(--theme-shadow-card);
|
box-shadow: var(--theme-shadow-card);
|
||||||
@@ -525,7 +584,7 @@ watch(showEditTimeEntryModal, (value) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-highlight) {
|
.fullcalendar :deep(.fc-highlight) {
|
||||||
background-color: var(--theme-color-default-background);
|
background-color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-select-mirror) {
|
.fullcalendar :deep(.fc-select-mirror) {
|
||||||
@@ -543,7 +602,7 @@ watch(showEditTimeEntryModal, (value) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-timegrid-body) {
|
.fullcalendar :deep(.fc-timegrid-body) {
|
||||||
background-color: var(--theme-color-default-background);
|
background-color: var(--background);
|
||||||
}
|
}
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-timegrid-col) {
|
.fullcalendar :deep(.fc-timegrid-col) {
|
||||||
@@ -610,4 +669,34 @@ watch(showEditTimeEntryModal, (value) => {
|
|||||||
.fullcalendar :deep(.fc-event-main) {
|
.fullcalendar :deep(.fc-event-main) {
|
||||||
padding: 0.125rem 0.25rem;
|
padding: 0.125rem 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Activity status plugin styles */
|
||||||
|
.fullcalendar :deep(.activity-status-box) {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullcalendar :deep(.activity-status-box.idle) {
|
||||||
|
background-color: rgba(239, 68, 68, 0.3) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullcalendar :deep(.activity-status-box.idle):hover {
|
||||||
|
background-color: rgba(239, 68, 68, 1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullcalendar :deep(.activity-status-box.active) {
|
||||||
|
background-color: rgba(34, 197, 94, 0.3) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullcalendar :deep(.activity-status-box.active):hover {
|
||||||
|
background-color: rgba(34, 197, 94, 1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add left margin to events only on days with activity status data */
|
||||||
|
.fullcalendar :deep(.has-activity-status .fc-timegrid-event-harness) {
|
||||||
|
margin-left: 15px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullcalendar :deep(.fc-timegrid-event) {
|
||||||
|
margin-left: 0 !important;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
184
resources/js/packages/ui/src/FullCalendar/idleStatusPlugin.ts
Normal file
184
resources/js/packages/ui/src/FullCalendar/idleStatusPlugin.ts
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import { createPlugin, PluginDef } from '@fullcalendar/core';
|
||||||
|
|
||||||
|
export interface ActivityPeriod {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
isIdle: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivityStatusPluginOptions {
|
||||||
|
activityPeriods?: ActivityPeriod[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extend FullCalendar's options interface
|
||||||
|
declare module '@fullcalendar/core' {
|
||||||
|
interface CalendarOptions {
|
||||||
|
activityPeriods?: ActivityPeriod[];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders activity status boxes in the calendar time grid
|
||||||
|
*/
|
||||||
|
export function renderActivityStatusBoxes(
|
||||||
|
calendarEl: HTMLElement,
|
||||||
|
activityPeriods: ActivityPeriod[]
|
||||||
|
) {
|
||||||
|
if (!calendarEl) return;
|
||||||
|
|
||||||
|
// Clean up existing activity boxes and markers first
|
||||||
|
const existingBoxes = calendarEl.querySelectorAll('.activity-status-box');
|
||||||
|
existingBoxes.forEach((box) => box.remove());
|
||||||
|
|
||||||
|
// Remove has-activity-status class from all lanes
|
||||||
|
const allLanes = calendarEl.querySelectorAll('.fc-timegrid-col');
|
||||||
|
allLanes.forEach((lane) => lane.classList.remove('has-activity-status'));
|
||||||
|
|
||||||
|
const timeGrid = calendarEl.querySelector('.fc-timegrid-body');
|
||||||
|
if (!timeGrid) {
|
||||||
|
console.log('No timegrid found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lanes = timeGrid.querySelectorAll('.fc-timegrid-col');
|
||||||
|
if (lanes.length === 0) {
|
||||||
|
console.log('No lanes found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
'Rendering activity status boxes, lanes:',
|
||||||
|
lanes.length,
|
||||||
|
'periods:',
|
||||||
|
activityPeriods.length
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get the calendar's current view to determine dates
|
||||||
|
const dateHeaders = calendarEl.querySelectorAll('.fc-col-header-cell');
|
||||||
|
|
||||||
|
lanes.forEach((lane: Element, dayIndex: number) => {
|
||||||
|
// Get the date for this lane from the data attribute
|
||||||
|
const laneEl = lane as HTMLElement;
|
||||||
|
const dateStr = laneEl.getAttribute('data-date');
|
||||||
|
|
||||||
|
if (!dateStr) {
|
||||||
|
console.log('No date attribute found for lane', dayIndex);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const laneDate = new Date(dateStr);
|
||||||
|
const laneDateStart = new Date(laneDate);
|
||||||
|
laneDateStart.setHours(0, 0, 0, 0);
|
||||||
|
const laneDateEnd = new Date(laneDate);
|
||||||
|
laneDateEnd.setHours(23, 59, 59, 999);
|
||||||
|
|
||||||
|
console.log('Processing lane', dayIndex, 'date:', dateStr);
|
||||||
|
|
||||||
|
let hasActivityStatusForThisDay = false;
|
||||||
|
|
||||||
|
activityPeriods.forEach((period) => {
|
||||||
|
const periodStart = new Date(period.start);
|
||||||
|
const periodEnd = new Date(period.end);
|
||||||
|
|
||||||
|
// Check if period overlaps with this day
|
||||||
|
if (periodEnd < laneDateStart || periodStart > laneDateEnd) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
'Rendering period',
|
||||||
|
period.isIdle ? 'idle' : 'active',
|
||||||
|
'from',
|
||||||
|
periodStart,
|
||||||
|
'to',
|
||||||
|
periodEnd
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculate the position and height of the idle box
|
||||||
|
const { top, height } = calculateBoxPosition(
|
||||||
|
calendarEl,
|
||||||
|
periodStart > laneDateStart ? periodStart : laneDateStart,
|
||||||
|
periodEnd < laneDateEnd ? periodEnd : laneDateEnd
|
||||||
|
);
|
||||||
|
|
||||||
|
if (height <= 0) return;
|
||||||
|
|
||||||
|
hasActivityStatusForThisDay = true;
|
||||||
|
|
||||||
|
// Create and append the activity status box
|
||||||
|
const box = document.createElement('div');
|
||||||
|
box.className = `activity-status-box ${period.isIdle ? 'idle' : 'active'}`;
|
||||||
|
box.style.position = 'absolute';
|
||||||
|
box.style.top = `${top}px`;
|
||||||
|
box.style.height = `${height}px`;
|
||||||
|
box.style.width = '8px';
|
||||||
|
box.style.left = '4px';
|
||||||
|
box.style.right = '4px';
|
||||||
|
box.style.zIndex = '10';
|
||||||
|
box.style.borderRadius = '4px';
|
||||||
|
|
||||||
|
// Position relative to the lane
|
||||||
|
const laneFrame = lane.querySelector('.fc-timegrid-col-frame');
|
||||||
|
if (laneFrame) {
|
||||||
|
laneFrame.appendChild(box);
|
||||||
|
} else {
|
||||||
|
console.log('No lane frame found');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mark this lane as having activity status if any periods were rendered
|
||||||
|
if (hasActivityStatusForThisDay) {
|
||||||
|
laneEl.classList.add('has-activity-status');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the pixel position and height for an activity status box
|
||||||
|
*/
|
||||||
|
function calculateBoxPosition(
|
||||||
|
calendarEl: HTMLElement,
|
||||||
|
startTime: Date,
|
||||||
|
endTime: Date
|
||||||
|
): { top: number; height: number } {
|
||||||
|
// Get the slot duration and slot height
|
||||||
|
const slotsEl = calendarEl.querySelectorAll('.fc-timegrid-slot');
|
||||||
|
if (slotsEl.length === 0) {
|
||||||
|
console.log('No slots found');
|
||||||
|
return { top: 0, height: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate slot height (assuming all slots are equal height)
|
||||||
|
const firstSlot = slotsEl[0] as HTMLElement;
|
||||||
|
const slotHeight = firstSlot.offsetHeight;
|
||||||
|
|
||||||
|
// Each slot is 15 minutes by default (configured in TimeEntryCalendar)
|
||||||
|
const slotDurationMinutes = 15;
|
||||||
|
const pixelsPerMinute = slotHeight / slotDurationMinutes;
|
||||||
|
|
||||||
|
// Calculate start position (minutes from midnight)
|
||||||
|
const startMinutes = startTime.getHours() * 60 + startTime.getMinutes();
|
||||||
|
const endMinutes = endTime.getHours() * 60 + endTime.getMinutes();
|
||||||
|
|
||||||
|
// Calculate pixel positions
|
||||||
|
const top = startMinutes * pixelsPerMinute;
|
||||||
|
const height = (endMinutes - startMinutes) * pixelsPerMinute;
|
||||||
|
|
||||||
|
return { top, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FullCalendar plugin to display idle/active status boxes in the time grid
|
||||||
|
*/
|
||||||
|
const activityStatusPlugin: PluginDef = createPlugin({
|
||||||
|
name: '@solidtime/activity-status',
|
||||||
|
|
||||||
|
optionRefiners: {
|
||||||
|
activityPeriods: (rawVal: any) => {
|
||||||
|
if (!Array.isArray(rawVal)) return [];
|
||||||
|
return rawVal;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default activityStatusPlugin;
|
||||||
@@ -38,6 +38,7 @@ import FullCalendarEventContent from './FullCalendar/FullCalendarEventContent.vu
|
|||||||
import FullCalendarDayHeader from './FullCalendar/FullCalendarDayHeader.vue';
|
import FullCalendarDayHeader from './FullCalendar/FullCalendarDayHeader.vue';
|
||||||
import TimeEntryCalendar from './FullCalendar/TimeEntryCalendar.vue';
|
import TimeEntryCalendar from './FullCalendar/TimeEntryCalendar.vue';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip/index';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip/index';
|
||||||
|
export type { ActivityPeriod } from './FullCalendar/idleStatusPlugin';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
money,
|
money,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export const solidtimeTheme = {
|
|||||||
'2xs': '16rem',
|
'2xs': '16rem',
|
||||||
},
|
},
|
||||||
fontSize: {
|
fontSize: {
|
||||||
|
'2xs': ['0.625rem', { lineHeight: '0.75rem' }],
|
||||||
xs: ['0.75rem', { lineHeight: '1rem' }],
|
xs: ['0.75rem', { lineHeight: '1rem' }],
|
||||||
sm: ['0.8125rem', { lineHeight: '1.125rem' }],
|
sm: ['0.8125rem', { lineHeight: '1.125rem' }],
|
||||||
base: ['0.875rem', { lineHeight: '1.25rem' }],
|
base: ['0.875rem', { lineHeight: '1.25rem' }],
|
||||||
|
|||||||
Reference in New Issue
Block a user