+
{{ clientName }}
-
+
{{ formattedDuration }}
diff --git a/resources/js/packages/ui/src/FullCalendar/TimeEntryCalendar.vue b/resources/js/packages/ui/src/FullCalendar/TimeEntryCalendar.vue
index e952ee63..41da898d 100644
--- a/resources/js/packages/ui/src/FullCalendar/TimeEntryCalendar.vue
+++ b/resources/js/packages/ui/src/FullCalendar/TimeEntryCalendar.vue
@@ -4,7 +4,16 @@ import dayGridPlugin from '@fullcalendar/daygrid';
import timeGridPlugin from '@fullcalendar/timegrid';
import interactionPlugin from '@fullcalendar/interaction';
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 { useCssVariable } from '@/utils/useCssVariable';
import { getDayJsInstance, getLocalizedDayJs } from '../utils/time';
@@ -12,6 +21,10 @@ import { getUserTimezone, getWeekStart } from '../utils/settings';
import { LoadingSpinner, TimeEntryCreateModal, TimeEntryEditModal } from '..';
import FullCalendarEventContent from './FullCalendarEventContent.vue';
import FullCalendarDayHeader from './FullCalendarDayHeader.vue';
+import activityStatusPlugin, {
+ type ActivityPeriod,
+ renderActivityStatusBoxes,
+} from './idleStatusPlugin';
import type {
TimeEntry,
Project,
@@ -37,6 +50,7 @@ const props = defineProps<{
tasks: Task[];
clients: Client[];
tags: Tag[];
+ activityPeriods?: ActivityPeriod[];
loading?: boolean;
// Permissions / feature flags
@@ -165,6 +179,8 @@ const dailyTotals = computed(() => {
function emitDatesChange(arg: DatesSetArg) {
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 }) {
@@ -234,7 +250,7 @@ async function handleEventResize(arg: EventChangeArg) {
}
const calendarOptions = computed(() => ({
- plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin],
+ plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin, activityStatusPlugin],
initialView: 'timeGridWeek',
headerToolbar: {
left: 'prev,next today',
@@ -265,6 +281,7 @@ const calendarOptions = computed(() => ({
datesSet: emitDatesChange,
events: events.value,
+ activityPeriods: props.activityPeriods || [],
}));
watch(showCreateTimeEntryModal, (value) => {
@@ -283,6 +300,48 @@ watch(showEditTimeEntryModal, (value) => {
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();
+});
@@ -377,11 +436,11 @@ watch(showEditTimeEntryModal, (value) => {
}
.fullcalendar :deep(.fc-timegrid-slot-label) {
- background-color: var(--theme-color-default-background);
+ background-color: var(--background);
}
.fullcalendar :deep(.fc-toolbar) {
- background-color: var(--theme-color-default-background);
+ background-color: var(--background);
padding: 0.5rem;
margin-bottom: 0;
}
@@ -463,7 +522,7 @@ watch(showEditTimeEntryModal, (value) => {
.fullcalendar :deep(.fc-event) {
border-radius: var(--radius);
- padding: 0.45rem 0.25rem;
+ padding: 0;
font-size: 0.75rem;
cursor: pointer;
box-shadow: var(--theme-shadow-card);
@@ -525,7 +584,7 @@ watch(showEditTimeEntryModal, (value) => {
}
.fullcalendar :deep(.fc-highlight) {
- background-color: var(--theme-color-default-background);
+ background-color: var(--primary);
}
.fullcalendar :deep(.fc-select-mirror) {
@@ -543,7 +602,7 @@ watch(showEditTimeEntryModal, (value) => {
}
.fullcalendar :deep(.fc-timegrid-body) {
- background-color: var(--theme-color-default-background);
+ background-color: var(--background);
}
.fullcalendar :deep(.fc-timegrid-col) {
@@ -610,4 +669,34 @@ watch(showEditTimeEntryModal, (value) => {
.fullcalendar :deep(.fc-event-main) {
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;
+}
diff --git a/resources/js/packages/ui/src/FullCalendar/idleStatusPlugin.ts b/resources/js/packages/ui/src/FullCalendar/idleStatusPlugin.ts
new file mode 100644
index 00000000..a4315dbc
--- /dev/null
+++ b/resources/js/packages/ui/src/FullCalendar/idleStatusPlugin.ts
@@ -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;
diff --git a/resources/js/packages/ui/src/index.ts b/resources/js/packages/ui/src/index.ts
index 59657c19..246ec592 100644
--- a/resources/js/packages/ui/src/index.ts
+++ b/resources/js/packages/ui/src/index.ts
@@ -38,6 +38,7 @@ import FullCalendarEventContent from './FullCalendar/FullCalendarEventContent.vu
import FullCalendarDayHeader from './FullCalendar/FullCalendarDayHeader.vue';
import TimeEntryCalendar from './FullCalendar/TimeEntryCalendar.vue';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip/index';
+export type { ActivityPeriod } from './FullCalendar/idleStatusPlugin';
export {
money,
diff --git a/resources/js/packages/ui/tailwind.theme.js b/resources/js/packages/ui/tailwind.theme.js
index 7d8ede57..8db2f83c 100644
--- a/resources/js/packages/ui/tailwind.theme.js
+++ b/resources/js/packages/ui/tailwind.theme.js
@@ -16,6 +16,7 @@ export const solidtimeTheme = {
'2xs': '16rem',
},
fontSize: {
+ '2xs': ['0.625rem', { lineHeight: '0.75rem' }],
xs: ['0.75rem', { lineHeight: '1rem' }],
sm: ['0.8125rem', { lineHeight: '1.125rem' }],
base: ['0.875rem', { lineHeight: '1.25rem' }],