mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 00:02:15 +01:00
Replace FullCalendar with custom calendar UI
This commit is contained in:
@@ -2,10 +2,11 @@ import type { Page } from '@playwright/test';
|
|||||||
import { expect } from '@playwright/test';
|
import { expect } from '@playwright/test';
|
||||||
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||||
import { test } from '../playwright/fixtures';
|
import { test } from '../playwright/fixtures';
|
||||||
|
import { createBareTimeEntryViaApi, createTimeEntryWithTimestampsViaApi } from './utils/api';
|
||||||
|
|
||||||
async function goToCalendar(page: Page) {
|
async function goToCalendar(page: Page) {
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/calendar');
|
await page.goto(PLAYWRIGHT_BASE_URL + '/calendar');
|
||||||
await expect(page.locator('.fc')).toBeVisible();
|
await expect(page.locator('.fc')).toBeVisible({ timeout: 10000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openSettingsPopover(page: Page) {
|
async function openSettingsPopover(page: Page) {
|
||||||
@@ -21,6 +22,25 @@ function getCalendarTitle(page: Page) {
|
|||||||
return page.getByTestId('calendar-title');
|
return page.getByTestId('calendar-title');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function scrollCalendarToTime(page: Page, time: string) {
|
||||||
|
await page.evaluate((t) => {
|
||||||
|
const slot = document.querySelector(`.fc-timegrid-slot-lane[data-time="${t}"]`);
|
||||||
|
if (slot) slot.scrollIntoView({ block: 'start' });
|
||||||
|
}, time);
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSlotHeight(page: Page): Promise<number> {
|
||||||
|
return await page.evaluate(() => {
|
||||||
|
const slots = Array.from(document.querySelectorAll('.fc-timegrid-slot-lane'));
|
||||||
|
for (let i = 0; i < slots.length; i++) {
|
||||||
|
const h = slots[i].getBoundingClientRect().height;
|
||||||
|
if (h > 0) return h;
|
||||||
|
}
|
||||||
|
return 20;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
test.describe('Calendar Settings', () => {
|
test.describe('Calendar Settings', () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await clearCalendarSettings(page);
|
await clearCalendarSettings(page);
|
||||||
@@ -253,3 +273,402 @@ test.describe('Calendar Toolbar', () => {
|
|||||||
await expect(page.locator('.fc-col-header-cell')).not.toHaveCount(1);
|
await expect(page.locator('.fc-col-header-cell')).not.toHaveCount(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe('Visual Snapping', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await clearCalendarSettings(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('snap interval of 1 minute allows fine-grained positioning', async ({ page, ctx }) => {
|
||||||
|
await goToCalendar(page);
|
||||||
|
await openSettingsPopover(page);
|
||||||
|
|
||||||
|
// Set snap interval to 1 min
|
||||||
|
await page.getByLabel('Snap Interval').click();
|
||||||
|
await page.getByRole('option', { name: '1 min' }).click();
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
|
||||||
|
// Create a 1h time entry
|
||||||
|
await createBareTimeEntryViaApi(ctx, 'Snap 1min test', '1h');
|
||||||
|
await goToCalendar(page);
|
||||||
|
|
||||||
|
// Scroll the calendar so the 14:00 target area is visible
|
||||||
|
await scrollCalendarToTime(page, '13:00:00');
|
||||||
|
|
||||||
|
const event = page.locator('.fc-event').first();
|
||||||
|
await expect(event).toBeVisible();
|
||||||
|
|
||||||
|
// Get target slot at a non-15-min boundary time
|
||||||
|
const targetSlot = page.locator('.fc-timegrid-slot-lane[data-time="14:00:00"]').first();
|
||||||
|
const targetBox = await targetSlot.boundingBox();
|
||||||
|
expect(targetBox).not.toBeNull();
|
||||||
|
|
||||||
|
// Drag event to a position offset from the 15-min boundary
|
||||||
|
const putResponsePromise = page.waitForResponse(
|
||||||
|
(resp) => resp.url().includes('/time-entries/') && resp.request().method() === 'PUT'
|
||||||
|
);
|
||||||
|
|
||||||
|
await event.hover();
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(targetBox!.x + targetBox!.width / 2, targetBox!.y + 5, { steps: 10 });
|
||||||
|
await page.mouse.up();
|
||||||
|
|
||||||
|
const putResponse = await putResponsePromise;
|
||||||
|
expect(putResponse.status()).toBe(200);
|
||||||
|
|
||||||
|
const body = await putResponse.json();
|
||||||
|
const startDate = new Date(body.data.start);
|
||||||
|
const minutes = startDate.getMinutes();
|
||||||
|
|
||||||
|
// With 1-min snap, any minute value is valid (0-59)
|
||||||
|
expect(minutes).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(minutes).toBeLessThanOrEqual(59);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('snap interval of 60 minutes creates hour-aligned entries', async ({ page, ctx }) => {
|
||||||
|
await goToCalendar(page);
|
||||||
|
await openSettingsPopover(page);
|
||||||
|
|
||||||
|
// Set snap interval to 60 min
|
||||||
|
await page.getByLabel('Snap Interval').click();
|
||||||
|
await page.getByRole('option', { name: '1 hour' }).click();
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
|
||||||
|
// Create a 1h time entry
|
||||||
|
await createBareTimeEntryViaApi(ctx, 'Snap 60min test', '1h');
|
||||||
|
await goToCalendar(page);
|
||||||
|
|
||||||
|
// Scroll the calendar so the 14:00 target area is visible
|
||||||
|
await scrollCalendarToTime(page, '13:00:00');
|
||||||
|
|
||||||
|
const event = page.locator('.fc-event').first();
|
||||||
|
await expect(event).toBeVisible();
|
||||||
|
|
||||||
|
// Get target slot
|
||||||
|
const targetSlot = page.locator('.fc-timegrid-slot-lane[data-time="14:00:00"]').first();
|
||||||
|
const targetBox = await targetSlot.boundingBox();
|
||||||
|
expect(targetBox).not.toBeNull();
|
||||||
|
|
||||||
|
// Drag event
|
||||||
|
const putResponsePromise = page.waitForResponse(
|
||||||
|
(resp) => resp.url().includes('/time-entries/') && resp.request().method() === 'PUT'
|
||||||
|
);
|
||||||
|
|
||||||
|
await event.hover();
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(targetBox!.x + targetBox!.width / 2, targetBox!.y + 5, { steps: 10 });
|
||||||
|
await page.mouse.up();
|
||||||
|
|
||||||
|
const putResponse = await putResponsePromise;
|
||||||
|
expect(putResponse.status()).toBe(200);
|
||||||
|
|
||||||
|
const body = await putResponse.json();
|
||||||
|
const startDate = new Date(body.data.start);
|
||||||
|
const minutes = startDate.getMinutes();
|
||||||
|
|
||||||
|
// With 60-min snap, minutes should be 0 (on the hour)
|
||||||
|
expect(minutes).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('changing snap interval mid-session affects next drag', async ({ page, ctx }) => {
|
||||||
|
// Create a 1h time entry
|
||||||
|
await createBareTimeEntryViaApi(ctx, 'Snap change test', '1h');
|
||||||
|
await goToCalendar(page);
|
||||||
|
|
||||||
|
// Set snap to 15 min
|
||||||
|
await openSettingsPopover(page);
|
||||||
|
await page.getByLabel('Snap Interval').click();
|
||||||
|
await page.getByRole('option', { name: '15 min' }).click();
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
|
||||||
|
// Scroll the calendar so the 14:00 target area is visible
|
||||||
|
await scrollCalendarToTime(page, '13:00:00');
|
||||||
|
|
||||||
|
const event = page.locator('.fc-event').first();
|
||||||
|
await expect(event).toBeVisible();
|
||||||
|
|
||||||
|
// Drag event to 14:00 area
|
||||||
|
const targetSlot14 = page.locator('.fc-timegrid-slot-lane[data-time="14:00:00"]').first();
|
||||||
|
const targetBox14 = await targetSlot14.boundingBox();
|
||||||
|
expect(targetBox14).not.toBeNull();
|
||||||
|
|
||||||
|
const putResponsePromise1 = page.waitForResponse(
|
||||||
|
(resp) => resp.url().includes('/time-entries/') && resp.request().method() === 'PUT'
|
||||||
|
);
|
||||||
|
|
||||||
|
await event.hover();
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(targetBox14!.x + targetBox14!.width / 2, targetBox14!.y + 5, {
|
||||||
|
steps: 10,
|
||||||
|
});
|
||||||
|
await page.mouse.up();
|
||||||
|
|
||||||
|
const putResponse1 = await putResponsePromise1;
|
||||||
|
expect(putResponse1.status()).toBe(200);
|
||||||
|
|
||||||
|
const body1 = await putResponse1.json();
|
||||||
|
const startDate1 = new Date(body1.data.start);
|
||||||
|
expect(startDate1.getMinutes() % 15).toBe(0);
|
||||||
|
|
||||||
|
// Wait for query re-fetch/re-renders to fully settle after drag
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
|
||||||
|
// Change snap to 30 min
|
||||||
|
// Use Escape first to ensure no stale popover is open, then re-open
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await openSettingsPopover(page);
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await page.getByLabel('Snap Interval').click({ force: true });
|
||||||
|
await page.getByRole('option', { name: '30 min' }).click();
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
|
||||||
|
// Scroll the calendar so the 10:00 target area is visible
|
||||||
|
await scrollCalendarToTime(page, '09:00:00');
|
||||||
|
|
||||||
|
// Drag event to 10:00 area
|
||||||
|
const targetSlot10 = page.locator('.fc-timegrid-slot-lane[data-time="10:00:00"]').first();
|
||||||
|
const targetBox10 = await targetSlot10.boundingBox();
|
||||||
|
expect(targetBox10).not.toBeNull();
|
||||||
|
|
||||||
|
const putResponsePromise2 = page.waitForResponse(
|
||||||
|
(resp) => resp.url().includes('/time-entries/') && resp.request().method() === 'PUT'
|
||||||
|
);
|
||||||
|
|
||||||
|
await event.hover();
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(targetBox10!.x + targetBox10!.width / 2, targetBox10!.y + 5, {
|
||||||
|
steps: 10,
|
||||||
|
});
|
||||||
|
await page.mouse.up();
|
||||||
|
|
||||||
|
const putResponse2 = await putResponsePromise2;
|
||||||
|
expect(putResponse2.status()).toBe(200);
|
||||||
|
|
||||||
|
const body2 = await putResponse2.json();
|
||||||
|
const startDate2 = new Date(body2.data.start);
|
||||||
|
expect(startDate2.getMinutes() % 30).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('snap with different grid scale (slot != snap)', async ({ page, ctx }) => {
|
||||||
|
await goToCalendar(page);
|
||||||
|
await openSettingsPopover(page);
|
||||||
|
|
||||||
|
// Set grid scale to 30 min, snap to 5 min
|
||||||
|
await page.getByLabel('Grid Scale').click();
|
||||||
|
await page.getByRole('option', { name: '30 min' }).click();
|
||||||
|
await page.getByLabel('Snap Interval').click();
|
||||||
|
await page.getByRole('option', { name: '5 min', exact: true }).click();
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
|
||||||
|
// Wait for re-render with 30-min grid
|
||||||
|
await expect(async () => {
|
||||||
|
const slotCount = await page.locator('.fc-timegrid-slot-lane').count();
|
||||||
|
// 24 hours * 2 slots/hour = 48 slots for 30-min grid
|
||||||
|
expect(slotCount).toBeLessThanOrEqual(48);
|
||||||
|
}).toPass({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Verify grid is 30-min (fewer slots than default 15-min)
|
||||||
|
const slotCount = await page.locator('.fc-timegrid-slot-lane').count();
|
||||||
|
// Default 15-min grid has 96 slots; 30-min grid should have 48
|
||||||
|
expect(slotCount).toBeLessThanOrEqual(48);
|
||||||
|
|
||||||
|
// Create a 1h time entry and go to calendar
|
||||||
|
await createBareTimeEntryViaApi(ctx, 'Grid snap test', '1h');
|
||||||
|
await goToCalendar(page);
|
||||||
|
|
||||||
|
// Re-apply settings since goToCalendar navigates
|
||||||
|
await openSettingsPopover(page);
|
||||||
|
await page.getByLabel('Grid Scale').click();
|
||||||
|
await page.getByRole('option', { name: '30 min' }).click();
|
||||||
|
await page.getByLabel('Snap Interval').click();
|
||||||
|
await page.getByRole('option', { name: '5 min', exact: true }).click();
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
|
||||||
|
const event = page.locator('.fc-event').first();
|
||||||
|
await expect(event).toBeVisible();
|
||||||
|
|
||||||
|
// Drag event
|
||||||
|
const targetSlot = page.locator('.fc-timegrid-slot-lane[data-time="14:00:00"]').first();
|
||||||
|
const targetBox = await targetSlot.boundingBox();
|
||||||
|
expect(targetBox).not.toBeNull();
|
||||||
|
|
||||||
|
const putResponsePromise = page.waitForResponse(
|
||||||
|
(resp) => resp.url().includes('/time-entries/') && resp.request().method() === 'PUT'
|
||||||
|
);
|
||||||
|
|
||||||
|
await event.hover();
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(targetBox!.x + targetBox!.width / 2, targetBox!.y + 5, { steps: 10 });
|
||||||
|
await page.mouse.up();
|
||||||
|
|
||||||
|
const putResponse = await putResponsePromise;
|
||||||
|
expect(putResponse.status()).toBe(200);
|
||||||
|
|
||||||
|
const body = await putResponse.json();
|
||||||
|
const startDate = new Date(body.data.start);
|
||||||
|
// Snap is 5 min, so minutes should be divisible by 5
|
||||||
|
expect(startDate.getMinutes() % 5).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Calendar Settings Effects', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await clearCalendarSettings(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('start/end time hides slots outside visible range', async ({ page, ctx }) => {
|
||||||
|
// Create a time entry at 6 AM today
|
||||||
|
const now = new Date();
|
||||||
|
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 6, 0, 0);
|
||||||
|
const end = new Date(start.getTime() + 3600 * 1000); // 7 AM
|
||||||
|
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||||||
|
description: 'Early morning entry',
|
||||||
|
start: start.toISOString().replace(/\.\d{3}Z$/, 'Z'),
|
||||||
|
end: end.toISOString().replace(/\.\d{3}Z$/, 'Z'),
|
||||||
|
});
|
||||||
|
|
||||||
|
await goToCalendar(page);
|
||||||
|
|
||||||
|
// Verify 6 AM slot is visible with default settings
|
||||||
|
await expect(page.locator('.fc-timegrid-slot[data-time="06:00:00"]')).not.toHaveCount(0);
|
||||||
|
|
||||||
|
// Set start time to 8 AM
|
||||||
|
await openSettingsPopover(page);
|
||||||
|
await page.getByLabel('Start Time').click();
|
||||||
|
await page.getByRole('option', { name: '8:00 AM' }).click();
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
|
||||||
|
// 6 AM slot should be hidden
|
||||||
|
await expect(page.locator('.fc-timegrid-slot[data-time="06:00:00"]')).toHaveCount(0);
|
||||||
|
|
||||||
|
// 8 AM slot should be visible
|
||||||
|
await expect(page.locator('.fc-timegrid-slot[data-time="08:00:00"]')).not.toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('grid scale affects event visual height proportionally', async ({ page, ctx }) => {
|
||||||
|
// Create a 1h time entry
|
||||||
|
await createBareTimeEntryViaApi(ctx, 'Height test', '1h');
|
||||||
|
await goToCalendar(page);
|
||||||
|
|
||||||
|
const event = page.locator('.fc-event').first();
|
||||||
|
await expect(event).toBeVisible();
|
||||||
|
await event.scrollIntoViewIfNeeded();
|
||||||
|
|
||||||
|
// Get event height with default 15-min grid scale
|
||||||
|
const box15 = await event.boundingBox();
|
||||||
|
expect(box15).not.toBeNull();
|
||||||
|
const height15 = box15!.height;
|
||||||
|
|
||||||
|
// Change grid scale to 60 min
|
||||||
|
await openSettingsPopover(page);
|
||||||
|
await page.getByLabel('Grid Scale').click();
|
||||||
|
await page.getByRole('option', { name: '1 hour' }).click();
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
|
||||||
|
// Wait for re-render and scroll event into view
|
||||||
|
await event.scrollIntoViewIfNeeded();
|
||||||
|
await expect(async () => {
|
||||||
|
const box = await event.boundingBox();
|
||||||
|
expect(box).not.toBeNull();
|
||||||
|
expect(box!.height).not.toBe(height15);
|
||||||
|
}).toPass({ timeout: 5000 });
|
||||||
|
|
||||||
|
const box60 = await event.boundingBox();
|
||||||
|
expect(box60).not.toBeNull();
|
||||||
|
const height60 = box60!.height;
|
||||||
|
|
||||||
|
// Event should appear smaller with larger grid scale
|
||||||
|
expect(height15).toBeGreaterThan(height60);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('snap interval affects drag granularity', async ({ page, ctx }) => {
|
||||||
|
await goToCalendar(page);
|
||||||
|
await openSettingsPopover(page);
|
||||||
|
|
||||||
|
// Set snap to 30 min
|
||||||
|
await page.getByLabel('Snap Interval').click();
|
||||||
|
await page.getByRole('option', { name: '30 min' }).click();
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
|
||||||
|
// Create a 1h time entry
|
||||||
|
await createBareTimeEntryViaApi(ctx, 'Drag granularity test', '1h');
|
||||||
|
await goToCalendar(page);
|
||||||
|
|
||||||
|
// Scroll the calendar so the 14:00 target area is visible
|
||||||
|
await scrollCalendarToTime(page, '13:00:00');
|
||||||
|
|
||||||
|
const event = page.locator('.fc-event').first();
|
||||||
|
await expect(event).toBeVisible();
|
||||||
|
|
||||||
|
// Get target slot
|
||||||
|
const targetSlot = page.locator('.fc-timegrid-slot-lane[data-time="14:00:00"]').first();
|
||||||
|
const targetBox = await targetSlot.boundingBox();
|
||||||
|
expect(targetBox).not.toBeNull();
|
||||||
|
|
||||||
|
// Drag event
|
||||||
|
const putResponsePromise = page.waitForResponse(
|
||||||
|
(resp) => resp.url().includes('/time-entries/') && resp.request().method() === 'PUT'
|
||||||
|
);
|
||||||
|
|
||||||
|
await event.hover();
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(targetBox!.x + targetBox!.width / 2, targetBox!.y + 5, { steps: 10 });
|
||||||
|
await page.mouse.up();
|
||||||
|
|
||||||
|
const putResponse = await putResponsePromise;
|
||||||
|
expect(putResponse.status()).toBe(200);
|
||||||
|
|
||||||
|
const body = await putResponse.json();
|
||||||
|
const startDate = new Date(body.data.start);
|
||||||
|
const minutes = startDate.getMinutes();
|
||||||
|
|
||||||
|
// With 30-min snap, minutes should be 0 or 30
|
||||||
|
expect(minutes % 30).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('settings apply immediately without page reload', async ({ page }) => {
|
||||||
|
await goToCalendar(page);
|
||||||
|
|
||||||
|
// Count slots with default grid scale (15 min)
|
||||||
|
const defaultSlotCount = await page.locator('.fc-timegrid-slot').count();
|
||||||
|
|
||||||
|
// Change grid scale to 30 min
|
||||||
|
await openSettingsPopover(page);
|
||||||
|
await page.getByLabel('Grid Scale').click();
|
||||||
|
await page.getByRole('option', { name: '30 min' }).click();
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
|
||||||
|
// Verify slot count changed without navigation
|
||||||
|
await expect(async () => {
|
||||||
|
const count = await page.locator('.fc-timegrid-slot').count();
|
||||||
|
expect(count).toBeLessThan(defaultSlotCount);
|
||||||
|
}).toPass({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Wait for FullCalendar to fully stabilize after re-render
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
await expect(page.locator('.fc')).toBeVisible();
|
||||||
|
|
||||||
|
// Change start time to 8 AM
|
||||||
|
// FullCalendar re-render from grid scale change can make popover elements unstable.
|
||||||
|
// Retry the open+click sequence if it fails.
|
||||||
|
await expect(async () => {
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await page.getByRole('button', { name: 'Calendar settings' }).click();
|
||||||
|
await expect(page.getByText('Calendar Settings')).toBeVisible();
|
||||||
|
const startTimeBtn = page.getByLabel('Start Time');
|
||||||
|
await expect(startTimeBtn).toBeVisible();
|
||||||
|
await startTimeBtn.click({ timeout: 3000 });
|
||||||
|
}).toPass({ timeout: 10000 });
|
||||||
|
|
||||||
|
await page.getByRole('option', { name: '8:00 AM' }).click();
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
|
||||||
|
// Verify 7 AM slot is hidden without reload
|
||||||
|
await expect(async () => {
|
||||||
|
const count = await page.locator('.fc-timegrid-slot[data-time="07:00:00"]').count();
|
||||||
|
expect(count).toBe(0);
|
||||||
|
}).toPass({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
2353
e2e/calendar.spec.ts
2353
e2e/calendar.spec.ts
File diff suppressed because it is too large
Load Diff
134
package-lock.json
generated
134
package-lock.json
generated
@@ -11,11 +11,6 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/core": "^1.6.0",
|
"@floating-ui/core": "^1.6.0",
|
||||||
"@floating-ui/vue": "^1.0.6",
|
"@floating-ui/vue": "^1.0.6",
|
||||||
"@fullcalendar/core": "^6.1.18",
|
|
||||||
"@fullcalendar/daygrid": "^6.1.18",
|
|
||||||
"@fullcalendar/interaction": "^6.1.18",
|
|
||||||
"@fullcalendar/timegrid": "^6.1.18",
|
|
||||||
"@fullcalendar/vue3": "^6.1.18",
|
|
||||||
"@heroicons/vue": "^2.1.1",
|
"@heroicons/vue": "^2.1.1",
|
||||||
"@rushstack/eslint-patch": "^1.10.5",
|
"@rushstack/eslint-patch": "^1.10.5",
|
||||||
"@tailwindcss/container-queries": "^0.1.1",
|
"@tailwindcss/container-queries": "^0.1.1",
|
||||||
@@ -25,7 +20,7 @@
|
|||||||
"@tanstack/vue-table": "^8.21.2",
|
"@tanstack/vue-table": "^8.21.2",
|
||||||
"@vue/eslint-config-prettier": "^10.2.0",
|
"@vue/eslint-config-prettier": "^10.2.0",
|
||||||
"@vue/eslint-config-typescript": "^14.3.0",
|
"@vue/eslint-config-typescript": "^14.3.0",
|
||||||
"@vueuse/core": "^14.2.0",
|
"@vueuse/core": "^14.2.1",
|
||||||
"@vueuse/integrations": "^14.0.0",
|
"@vueuse/integrations": "^14.0.0",
|
||||||
"@zodios/core": "^10.9.6",
|
"@zodios/core": "^10.9.6",
|
||||||
"chroma-js": "3.1.2",
|
"chroma-js": "3.1.2",
|
||||||
@@ -38,7 +33,7 @@
|
|||||||
"parse-duration": "^2.0.1",
|
"parse-duration": "^2.0.1",
|
||||||
"pinia": "^3.0.0",
|
"pinia": "^3.0.0",
|
||||||
"radix-vue": "^1.9.6",
|
"radix-vue": "^1.9.6",
|
||||||
"reka-ui": "^2.8.0",
|
"reka-ui": "^2.8.2",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"vue-echarts": "^8.0.0",
|
"vue-echarts": "^8.0.0",
|
||||||
@@ -1037,55 +1032,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@fullcalendar/core": {
|
|
||||||
"version": "6.1.20",
|
|
||||||
"resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.20.tgz",
|
|
||||||
"integrity": "sha512-1cukXLlePFiJ8YKXn/4tMKsy0etxYLCkXk8nUCFi11nRONF2Ba2CD5b21/ovtOO2tL6afTJfwmc1ed3HG7eB1g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"preact": "~10.12.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@fullcalendar/daygrid": {
|
|
||||||
"version": "6.1.20",
|
|
||||||
"resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.20.tgz",
|
|
||||||
"integrity": "sha512-AO9vqhkLP77EesmJzuU+IGXgxNulsA8mgQHynclJ8U70vSwAVnbcLG9qftiTAFSlZjiY/NvhE7sflve6cJelyQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"@fullcalendar/core": "~6.1.20"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@fullcalendar/interaction": {
|
|
||||||
"version": "6.1.20",
|
|
||||||
"resolved": "https://registry.npmjs.org/@fullcalendar/interaction/-/interaction-6.1.20.tgz",
|
|
||||||
"integrity": "sha512-p6txmc5txL0bMiPaJxe2ip6o0T384TyoD2KGdsU6UjZ5yoBlaY+dg7kxfnYKpYMzEJLG58n+URrHr2PgNL2fyA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"@fullcalendar/core": "~6.1.20"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@fullcalendar/timegrid": {
|
|
||||||
"version": "6.1.20",
|
|
||||||
"resolved": "https://registry.npmjs.org/@fullcalendar/timegrid/-/timegrid-6.1.20.tgz",
|
|
||||||
"integrity": "sha512-4H+/MWbz3ntA50lrPif+7TsvMeX3R1GSYjiLULz0+zEJ7/Yfd9pupZmAwUs/PBpA6aAcFmeRr0laWfcz1a9V1A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@fullcalendar/daygrid": "~6.1.20"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@fullcalendar/core": "~6.1.20"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@fullcalendar/vue3": {
|
|
||||||
"version": "6.1.20",
|
|
||||||
"resolved": "https://registry.npmjs.org/@fullcalendar/vue3/-/vue3-6.1.20.tgz",
|
|
||||||
"integrity": "sha512-8qg6pS27II9QBwFkkJC+7SfflMpWqOe7i3ii5ODq9KpLAjwQAd/zjfq8RvKR1Yryoh5UmMCmvRbMB7i4RGtqog==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"@fullcalendar/core": "~6.1.20",
|
|
||||||
"vue": "^3.0.11"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@heroicons/vue": {
|
"node_modules/@heroicons/vue": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@heroicons/vue/-/vue-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@heroicons/vue/-/vue-2.2.0.tgz",
|
||||||
@@ -2996,14 +2942,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vueuse/core": {
|
"node_modules/@vueuse/core": {
|
||||||
"version": "14.2.0",
|
"version": "14.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.2.1.tgz",
|
||||||
"integrity": "sha512-tpjzVl7KCQNVd/qcaCE9XbejL38V6KJAEq/tVXj7mDPtl6JtzmUdnXelSS+ULRkkrDgzYVK7EerQJvd2jR794Q==",
|
"integrity": "sha512-3vwDzV+GDUNpdegRY6kzpLm4Igptq+GA0QkJ3W61Iv27YWwW/ufSlOfgQIpN6FZRMG0mkaz4gglJRtq5SeJyIQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/web-bluetooth": "^0.0.21",
|
"@types/web-bluetooth": "^0.0.21",
|
||||||
"@vueuse/metadata": "14.2.0",
|
"@vueuse/metadata": "14.2.1",
|
||||||
"@vueuse/shared": "14.2.0"
|
"@vueuse/shared": "14.2.1"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/antfu"
|
"url": "https://github.com/sponsors/antfu"
|
||||||
@@ -3012,6 +2958,18 @@
|
|||||||
"vue": "^3.5.0"
|
"vue": "^3.5.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@vueuse/core/node_modules/@vueuse/shared": {
|
||||||
|
"version": "14.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.2.1.tgz",
|
||||||
|
"integrity": "sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/antfu"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": "^3.5.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vueuse/integrations": {
|
"node_modules/@vueuse/integrations": {
|
||||||
"version": "14.2.0",
|
"version": "14.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vueuse/integrations/-/integrations-14.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vueuse/integrations/-/integrations-14.2.0.tgz",
|
||||||
@@ -3078,7 +3036,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vueuse/metadata": {
|
"node_modules/@vueuse/integrations/node_modules/@vueuse/core": {
|
||||||
|
"version": "14.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.2.0.tgz",
|
||||||
|
"integrity": "sha512-tpjzVl7KCQNVd/qcaCE9XbejL38V6KJAEq/tVXj7mDPtl6JtzmUdnXelSS+ULRkkrDgzYVK7EerQJvd2jR794Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/web-bluetooth": "^0.0.21",
|
||||||
|
"@vueuse/metadata": "14.2.0",
|
||||||
|
"@vueuse/shared": "14.2.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/antfu"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": "^3.5.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vueuse/integrations/node_modules/@vueuse/metadata": {
|
||||||
"version": "14.2.0",
|
"version": "14.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.2.0.tgz",
|
||||||
"integrity": "sha512-i3axTGjU8b13FtyR4Keeama+43iD+BwX9C2TmzBVKqjSHArF03hjkp2SBZ1m72Jk2UtrX0aYCugBq2R1fhkuAQ==",
|
"integrity": "sha512-i3axTGjU8b13FtyR4Keeama+43iD+BwX9C2TmzBVKqjSHArF03hjkp2SBZ1m72Jk2UtrX0aYCugBq2R1fhkuAQ==",
|
||||||
@@ -3087,6 +3062,15 @@
|
|||||||
"url": "https://github.com/sponsors/antfu"
|
"url": "https://github.com/sponsors/antfu"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@vueuse/metadata": {
|
||||||
|
"version": "14.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.2.1.tgz",
|
||||||
|
"integrity": "sha512-1ButlVtj5Sb/HDtIy1HFr1VqCP4G6Ypqt5MAo0lCgjokrk2mvQKsK2uuy0vqu/Ks+sHfuHo0B9Y9jn9xKdjZsw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/antfu"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vueuse/shared": {
|
"node_modules/@vueuse/shared": {
|
||||||
"version": "14.2.0",
|
"version": "14.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.2.0.tgz",
|
||||||
@@ -5846,16 +5830,6 @@
|
|||||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/preact": {
|
|
||||||
"version": "10.12.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz",
|
|
||||||
"integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/preact"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/prelude-ls": {
|
"node_modules/prelude-ls": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||||
@@ -6118,9 +6092,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/reka-ui": {
|
"node_modules/reka-ui": {
|
||||||
"version": "2.8.0",
|
"version": "2.8.2",
|
||||||
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.8.2.tgz",
|
||||||
"integrity": "sha512-N4JOyIrmDE7w2i06WytqcV2QICubtS2PsK5Uo8FIMAgmO13KhUAgAByP26cXjjm2oF/w7rTyRs8YaqtvaBT+SA==",
|
"integrity": "sha512-8lTKcJhmG+D3UyJxhBnNnW/720sLzm0pbA9AC1MWazmJ5YchJAyTSl+O00xP/kxBmEN0fw5JqWVHguiFmsGjzA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/dom": "^1.6.13",
|
"@floating-ui/dom": "^1.6.13",
|
||||||
@@ -6134,6 +6108,10 @@
|
|||||||
"defu": "^6.1.4",
|
"defu": "^6.1.4",
|
||||||
"ohash": "^2.0.11"
|
"ohash": "^2.0.11"
|
||||||
},
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/zernonia"
|
||||||
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"vue": ">= 3.2.0"
|
"vue": ">= 3.2.0"
|
||||||
}
|
}
|
||||||
@@ -7442,12 +7420,18 @@
|
|||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@floating-ui/vue": "^1.1.4",
|
"@floating-ui/vue": "^1.1.4",
|
||||||
"@heroicons/vue": "^2.1.5",
|
"@heroicons/vue": "^2.1.5",
|
||||||
|
"@internationalized/date": "^3.0.0",
|
||||||
"@vitejs/plugin-vue": "^5.1.2 || ^6.0.0",
|
"@vitejs/plugin-vue": "^5.1.2 || ^6.0.0",
|
||||||
"@vueuse/core": "^12.5.0 || ^14.0.0",
|
"@vueuse/core": "^12.5.0 || ^14.0.0",
|
||||||
|
"@vueuse/integrations": "^12.5.0 || ^14.0.0",
|
||||||
|
"chroma-js": "^3.1.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
|
"focus-trap": "^7.0.0 || ^8.0.0",
|
||||||
|
"lucide-vue-next": ">=0.453.0",
|
||||||
"parse-duration": "^2.0.1",
|
"parse-duration": "^2.0.1",
|
||||||
|
"radix-vue": "^1.9.0",
|
||||||
"reka-ui": "^2.2.0",
|
"reka-ui": "^2.2.0",
|
||||||
"tailwind-merge": "^2.5.2",
|
"tailwind-merge": "^2.5.2",
|
||||||
"tailwindcss": "^3.1.0",
|
"tailwindcss": "^3.1.0",
|
||||||
|
|||||||
@@ -45,11 +45,6 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/core": "^1.6.0",
|
"@floating-ui/core": "^1.6.0",
|
||||||
"@floating-ui/vue": "^1.0.6",
|
"@floating-ui/vue": "^1.0.6",
|
||||||
"@fullcalendar/core": "^6.1.18",
|
|
||||||
"@fullcalendar/daygrid": "^6.1.18",
|
|
||||||
"@fullcalendar/interaction": "^6.1.18",
|
|
||||||
"@fullcalendar/timegrid": "^6.1.18",
|
|
||||||
"@fullcalendar/vue3": "^6.1.18",
|
|
||||||
"@heroicons/vue": "^2.1.1",
|
"@heroicons/vue": "^2.1.1",
|
||||||
"@rushstack/eslint-patch": "^1.10.5",
|
"@rushstack/eslint-patch": "^1.10.5",
|
||||||
"@tailwindcss/container-queries": "^0.1.1",
|
"@tailwindcss/container-queries": "^0.1.1",
|
||||||
@@ -59,7 +54,7 @@
|
|||||||
"@tanstack/vue-table": "^8.21.2",
|
"@tanstack/vue-table": "^8.21.2",
|
||||||
"@vue/eslint-config-prettier": "^10.2.0",
|
"@vue/eslint-config-prettier": "^10.2.0",
|
||||||
"@vue/eslint-config-typescript": "^14.3.0",
|
"@vue/eslint-config-typescript": "^14.3.0",
|
||||||
"@vueuse/core": "^14.2.0",
|
"@vueuse/core": "^14.2.1",
|
||||||
"@vueuse/integrations": "^14.0.0",
|
"@vueuse/integrations": "^14.0.0",
|
||||||
"@zodios/core": "^10.9.6",
|
"@zodios/core": "^10.9.6",
|
||||||
"chroma-js": "3.1.2",
|
"chroma-js": "3.1.2",
|
||||||
@@ -72,7 +67,7 @@
|
|||||||
"parse-duration": "^2.0.1",
|
"parse-duration": "^2.0.1",
|
||||||
"pinia": "^3.0.0",
|
"pinia": "^3.0.0",
|
||||||
"radix-vue": "^1.9.6",
|
"radix-vue": "^1.9.6",
|
||||||
"reka-ui": "^2.8.0",
|
"reka-ui": "^2.8.2",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"vue-echarts": "^8.0.0",
|
"vue-echarts": "^8.0.0",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||||
import { useTimeEntriesCalendarQuery } from '@/utils/useTimeEntriesCalendarQuery';
|
import { useTimeEntriesCalendarQuery } from '@/utils/useTimeEntriesCalendarQuery';
|
||||||
import { useTimeEntriesMutations } from '@/utils/useTimeEntriesMutations';
|
import { useTimeEntriesMutations } from '@/utils/useTimeEntriesMutations';
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref, onMounted } from 'vue';
|
||||||
import { useQueryClient } from '@tanstack/vue-query';
|
import { useQueryClient } from '@tanstack/vue-query';
|
||||||
import {
|
import {
|
||||||
type Client,
|
type Client,
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
type Project,
|
type Project,
|
||||||
} from '@/packages/api/src';
|
} from '@/packages/api/src';
|
||||||
import { TimeEntryCalendar } from '@/packages/ui/src';
|
import { TimeEntryCalendar } from '@/packages/ui/src';
|
||||||
|
import type { ActivityPeriod } from '@/packages/ui/src/FullCalendar/activityTypes';
|
||||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||||
import { useTagsStore } from '@/utils/useTags';
|
import { useTagsStore } from '@/utils/useTags';
|
||||||
import { useProjectsQuery } from '@/utils/useProjectsQuery';
|
import { useProjectsQuery } from '@/utils/useProjectsQuery';
|
||||||
@@ -26,6 +27,26 @@ import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
|
|||||||
const calendarStart = ref<Date | undefined>(undefined);
|
const calendarStart = ref<Date | undefined>(undefined);
|
||||||
const calendarEnd = ref<Date | undefined>(undefined);
|
const calendarEnd = ref<Date | undefined>(undefined);
|
||||||
|
|
||||||
|
// Test-injectable activity periods (for E2E testing).
|
||||||
|
// These hooks are no-ops in production — they only take effect when test code
|
||||||
|
// explicitly sets window globals, so they are safe to ship.
|
||||||
|
const testActivityPeriods = ref<ActivityPeriod[]>([]);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
(window as Record<string, unknown>).__TEST_SET_ACTIVITY_PERIODS__ = (
|
||||||
|
data: ActivityPeriod[]
|
||||||
|
) => {
|
||||||
|
testActivityPeriods.value = data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const windowData = (window as Record<string, unknown>).__TEST_ACTIVITY_PERIODS__;
|
||||||
|
if (Array.isArray(windowData)) {
|
||||||
|
setTimeout(() => {
|
||||||
|
testActivityPeriods.value = windowData;
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const { data: timeEntryResponse, isLoading: timeEntriesLoading } = useTimeEntriesCalendarQuery(
|
const { data: timeEntryResponse, isLoading: timeEntriesLoading } = useTimeEntriesCalendarQuery(
|
||||||
calendarStart,
|
calendarStart,
|
||||||
calendarEnd
|
calendarEnd
|
||||||
@@ -89,7 +110,10 @@ function onRefresh() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout title="Calendar" data-testid="calendar_view" main-class="p-0">
|
<AppLayout
|
||||||
|
title="Calendar"
|
||||||
|
data-testid="calendar_view"
|
||||||
|
main-class="p-0 min-h-0 overflow-hidden">
|
||||||
<TimeEntryCalendar
|
<TimeEntryCalendar
|
||||||
:time-entries="currentTimeEntries"
|
:time-entries="currentTimeEntries"
|
||||||
:projects="projects"
|
:projects="projects"
|
||||||
@@ -106,6 +130,7 @@ function onRefresh() {
|
|||||||
:create-client="createClient"
|
:create-client="createClient"
|
||||||
:create-project="createProject"
|
:create-project="createProject"
|
||||||
:create-tag="createTag"
|
:create-tag="createTag"
|
||||||
|
:activity-periods="testActivityPeriods"
|
||||||
@dates-change="onDatesChange"
|
@dates-change="onDatesChange"
|
||||||
@refresh="onRefresh" />
|
@refresh="onRefresh" />
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
|
|||||||
283
resources/js/packages/ui/src/FullCalendar/CalendarDayColumn.vue
Normal file
283
resources/js/packages/ui/src/FullCalendar/CalendarDayColumn.vue
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import FullCalendarEventContent from './FullCalendarEventContent.vue';
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '..';
|
||||||
|
import type { DayEvent, ActivityBox } from './calendarTypes';
|
||||||
|
import type { WindowActivityInPeriod } from './activityTypes';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
dayStr: string;
|
||||||
|
totalGridHeight: number;
|
||||||
|
hasActivityStatus: boolean;
|
||||||
|
|
||||||
|
// Events
|
||||||
|
dayEvents: DayEvent[];
|
||||||
|
getEventStyle: (dayEvent: DayEvent, dayStr: string) => Record<string, string>;
|
||||||
|
getEventOpacityClass: (dayEvent: DayEvent, dayStr: string) => string;
|
||||||
|
getEventDurationSeconds: (dayEvent: DayEvent, dayStr: string) => number;
|
||||||
|
|
||||||
|
// Drag state
|
||||||
|
isDragging: boolean;
|
||||||
|
dragEventId: string | null;
|
||||||
|
dragPreview: Record<string, string> | undefined;
|
||||||
|
|
||||||
|
// Resize state
|
||||||
|
resizeEventId: string | null;
|
||||||
|
resizeCrossDayPreview: Record<string, string> | undefined;
|
||||||
|
|
||||||
|
// Now indicator
|
||||||
|
showNowIndicator: boolean;
|
||||||
|
nowIndicatorTop: number;
|
||||||
|
|
||||||
|
// Activity boxes
|
||||||
|
activityBoxes: ActivityBox[];
|
||||||
|
getActivityBoxLabel: (box: ActivityBox) => string;
|
||||||
|
getActivityBoxActivities: (box: ActivityBox) => WindowActivityInPeriod[];
|
||||||
|
getActivityPercentage: (count: number, total: number) => string;
|
||||||
|
getActivityText: (activity: WindowActivityInPeriod) => string;
|
||||||
|
|
||||||
|
// Selection
|
||||||
|
showSelection: boolean;
|
||||||
|
isSelectionStart: boolean;
|
||||||
|
isSelectionIntermediate: boolean;
|
||||||
|
isSelectionEnd: boolean;
|
||||||
|
selectionTop: number;
|
||||||
|
selectionHeight: number;
|
||||||
|
selectionEndTop: number;
|
||||||
|
selectionEndHeight: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'event-pointerdown', event: PointerEvent, dayEvent: DayEvent): void;
|
||||||
|
(e: 'event-keydown-enter', dayEvent: DayEvent): void;
|
||||||
|
(
|
||||||
|
e: 'resizer-pointerdown',
|
||||||
|
event: PointerEvent,
|
||||||
|
dayEvent: DayEvent,
|
||||||
|
edge: 'start' | 'end'
|
||||||
|
): void;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="fc-timegrid-col relative border-r border-border bg-transparent pointer-events-none"
|
||||||
|
:class="{
|
||||||
|
'has-activity-status': hasActivityStatus,
|
||||||
|
}"
|
||||||
|
:data-date="dayStr"
|
||||||
|
:style="{ height: totalGridHeight + 'px' }">
|
||||||
|
<div
|
||||||
|
class="absolute inset-y-0 left-0.5 right-0.5"
|
||||||
|
:class="{ 'fc-events-inset': hasActivityStatus }">
|
||||||
|
<div
|
||||||
|
v-for="dayEvent in dayEvents"
|
||||||
|
:key="dayEvent.event.id"
|
||||||
|
class="fc-event group pointer-events-auto rounded-sm text-xs cursor-pointer shadow-card overflow-hidden border border-border touch-none select-none hover:shadow-dropdown focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
|
||||||
|
:class="[
|
||||||
|
getEventOpacityClass(dayEvent, dayStr),
|
||||||
|
{
|
||||||
|
'running-entry rounded-b-none': dayEvent.event.isRunning,
|
||||||
|
'fc-event-dragging': isDragging && dragEventId === dayEvent.event.id,
|
||||||
|
'fc-event-resizing': resizeEventId === dayEvent.event.id,
|
||||||
|
'rounded-t-none': dayEvent.isClippedStart,
|
||||||
|
'rounded-b-none': dayEvent.isClippedEnd,
|
||||||
|
'fc-event-clipped-start': dayEvent.isClippedStart,
|
||||||
|
'fc-event-clipped-end': dayEvent.isClippedEnd,
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
:data-event-id="dayEvent.event.id"
|
||||||
|
:style="getEventStyle(dayEvent, dayStr)"
|
||||||
|
tabindex="0"
|
||||||
|
:aria-label="dayEvent.event.title"
|
||||||
|
role="button"
|
||||||
|
@pointerdown="emit('event-pointerdown', $event, dayEvent)"
|
||||||
|
@keydown.enter.prevent="
|
||||||
|
!dayEvent.event.isRunning && emit('event-keydown-enter', dayEvent)
|
||||||
|
">
|
||||||
|
<div
|
||||||
|
v-if="!dayEvent.isClippedStart"
|
||||||
|
class="fc-event-resizer fc-event-resizer-start absolute z-[99] w-full h-3 left-0 top-[-2px] cursor-row-resize flex items-center justify-center opacity-0 group-hover:opacity-100"
|
||||||
|
@pointerdown.stop.prevent="
|
||||||
|
emit('resizer-pointerdown', $event, dayEvent, 'start')
|
||||||
|
"></div>
|
||||||
|
<div class="px-1 py-0.5 h-full overflow-hidden">
|
||||||
|
<FullCalendarEventContent
|
||||||
|
:title="dayEvent.event.title"
|
||||||
|
:project-name="dayEvent.event.project?.name"
|
||||||
|
:task-name="dayEvent.event.task?.name"
|
||||||
|
:client-name="dayEvent.event.client?.name"
|
||||||
|
:duration-seconds="getEventDurationSeconds(dayEvent, dayStr)" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="!dayEvent.event.isRunning && !dayEvent.isClippedEnd"
|
||||||
|
class="fc-event-resizer fc-event-resizer-end absolute z-[99] w-full h-3 left-0 bottom-[-2px] cursor-row-resize flex items-center justify-center opacity-0 group-hover:opacity-100"
|
||||||
|
@pointerdown.stop.prevent="
|
||||||
|
emit('resizer-pointerdown', $event, dayEvent, 'end')
|
||||||
|
"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="showNowIndicator"
|
||||||
|
class="fc-timegrid-now-indicator-line absolute left-0 right-0 border-t-2 border-red-500 z-50 pointer-events-none"
|
||||||
|
:style="{ top: nowIndicatorTop + 'px' }"></div>
|
||||||
|
|
||||||
|
<TooltipProvider :delay-duration="0">
|
||||||
|
<Tooltip v-for="(abox, ai) in activityBoxes" :key="'activity-' + ai">
|
||||||
|
<TooltipTrigger as-child>
|
||||||
|
<div
|
||||||
|
class="activity-status-box"
|
||||||
|
:class="abox.isIdle ? 'idle' : 'active'"
|
||||||
|
:style="{ top: abox.top + 'px', height: abox.height + 'px' }"></div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right" :side-offset="8">
|
||||||
|
<template v-if="getActivityBoxActivities(abox).length === 0">
|
||||||
|
{{ getActivityBoxLabel(abox) }}
|
||||||
|
</template>
|
||||||
|
<div v-else class="max-w-[300px]">
|
||||||
|
<div class="font-semibold mb-2">{{ getActivityBoxLabel(abox) }}</div>
|
||||||
|
<div
|
||||||
|
v-for="(activity, actIdx) in getActivityBoxActivities(abox).slice(0, 5)"
|
||||||
|
:key="actIdx"
|
||||||
|
class="mt-1 text-[11px] opacity-90 flex items-center gap-1.5">
|
||||||
|
<img
|
||||||
|
v-if="activity.icon"
|
||||||
|
:src="activity.icon"
|
||||||
|
:alt="activity.appName"
|
||||||
|
class="w-4 h-4 rounded-sm shrink-0" />
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="w-4 h-4 rounded-sm bg-white/10 flex items-center justify-center text-[8px] shrink-0">
|
||||||
|
{{ activity.appName.charAt(0).toUpperCase() }}
|
||||||
|
</div>
|
||||||
|
<span class="flex-1 overflow-hidden text-ellipsis whitespace-nowrap">
|
||||||
|
{{
|
||||||
|
getActivityPercentage(
|
||||||
|
activity.count,
|
||||||
|
getActivityBoxActivities(abox).reduce(
|
||||||
|
(sum, a) => sum + a.count,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}}%
|
||||||
|
{{ getActivityText(activity) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="getActivityBoxActivities(abox).length > 5"
|
||||||
|
class="mt-1 text-[11px] opacity-70 italic">
|
||||||
|
...and {{ getActivityBoxActivities(abox).length - 5 }} more
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="showSelection && isSelectionStart"
|
||||||
|
class="absolute inset-x-0 pointer-events-none bg-accent border border-primary z-[2]"
|
||||||
|
:style="{
|
||||||
|
top: selectionTop + 'px',
|
||||||
|
height: selectionHeight + 'px',
|
||||||
|
}"></div>
|
||||||
|
<div
|
||||||
|
v-if="showSelection && isSelectionIntermediate"
|
||||||
|
class="absolute inset-x-0 pointer-events-none bg-accent border border-primary z-[2]"
|
||||||
|
:style="{
|
||||||
|
top: '0px',
|
||||||
|
height: totalGridHeight + 'px',
|
||||||
|
}"></div>
|
||||||
|
<div
|
||||||
|
v-if="showSelection && isSelectionEnd"
|
||||||
|
class="absolute inset-x-0 pointer-events-none bg-accent border border-primary z-[2]"
|
||||||
|
:style="{
|
||||||
|
top: selectionEndTop + 'px',
|
||||||
|
height: selectionEndHeight + 'px',
|
||||||
|
}"></div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="isDragging && dragPreview"
|
||||||
|
class="fc-cross-day-preview pointer-events-none mx-px"
|
||||||
|
:style="dragPreview"></div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="resizeCrossDayPreview"
|
||||||
|
class="fc-cross-day-preview pointer-events-none mx-px"
|
||||||
|
:style="resizeCrossDayPreview"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fc-event-resizer::after {
|
||||||
|
content: '';
|
||||||
|
width: 24px;
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 1.5px;
|
||||||
|
background: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
.fc-event-resizer:hover::after {
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc-event-resizing,
|
||||||
|
.fc-event-resizing .fc-event-resizer {
|
||||||
|
cursor: row-resize !important;
|
||||||
|
}
|
||||||
|
.fc-event-resizing {
|
||||||
|
box-shadow: var(--theme-shadow-dropdown);
|
||||||
|
}
|
||||||
|
.fc-event-resizing .fc-event-resizer {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.fc-event-resizing .fc-event-resizer::after {
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.running-entry .fc-event-resizer-end {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc-timegrid-now-indicator-line::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -5px;
|
||||||
|
left: -4px;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-status-box {
|
||||||
|
position: absolute;
|
||||||
|
width: 10px;
|
||||||
|
left: 0;
|
||||||
|
z-index: 10;
|
||||||
|
cursor: default;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.activity-status-box::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 5px;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
.activity-status-box.idle::before {
|
||||||
|
background-color: rgba(156, 163, 175, 0.1);
|
||||||
|
}
|
||||||
|
.activity-status-box.idle:hover::before {
|
||||||
|
background-color: rgba(156, 163, 175, 0.5);
|
||||||
|
}
|
||||||
|
.activity-status-box.active::before {
|
||||||
|
background-color: rgba(34, 197, 94, 0.3);
|
||||||
|
}
|
||||||
|
.activity-status-box.active:hover::before {
|
||||||
|
background-color: rgba(34, 197, 94, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc-events-inset {
|
||||||
|
left: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,30 +1,28 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, inject, type ComputedRef } from 'vue';
|
import { computed, inject, type ComputedRef } from 'vue';
|
||||||
import { formatDate, formatHumanReadableDuration } from '../utils/time';
|
import { formatHumanReadableDuration } from '../utils/time';
|
||||||
import type { Organization } from '@/packages/api/src';
|
import type { Organization } from '@/packages/api/src';
|
||||||
import type { Dayjs } from 'dayjs';
|
import type { Dayjs } from 'dayjs';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
date: Dayjs;
|
date: Dayjs;
|
||||||
totalSeconds?: number;
|
totalSeconds?: number;
|
||||||
|
isToday?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const totalSecondsValue = computed(() => props.totalSeconds ?? 0);
|
const totalSecondsValue = computed(() => props.totalSeconds ?? 0);
|
||||||
|
|
||||||
// Injected organization for formatting settings
|
|
||||||
const organization = inject('organization') as ComputedRef<Organization | undefined> | undefined;
|
const organization = inject('organization') as ComputedRef<Organization | undefined> | undefined;
|
||||||
const intervalFormat = computed(() => organization?.value?.interval_format);
|
const intervalFormat = computed(() => organization?.value?.interval_format);
|
||||||
const numberFormat = computed(() => organization?.value?.number_format);
|
const numberFormat = computed(() => organization?.value?.number_format);
|
||||||
const dateFormat = computed(() => organization?.value?.date_format);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="fc-day-header-custom">
|
<div class="fc-day-header-custom">
|
||||||
<div class="text-xs text-muted-foreground font-medium">
|
<div class="text-sm text-foreground" :class="isToday ? 'font-semibold' : 'font-medium'">
|
||||||
{{ date.format('ddd') }}
|
{{ date.format('ddd') }} {{ date.date() }}
|
||||||
</div>
|
</div>
|
||||||
<span class="text-xs">{{ formatDate(date.toISOString(), dateFormat) }}</span>
|
<span class="block text-xs text-muted-foreground font-medium mt-0.5">
|
||||||
<span class="block text-xs text-muted-foreground font-medium mt-1">
|
|
||||||
{{ formatHumanReadableDuration(totalSecondsValue, intervalFormat, numberFormat) }}
|
{{ formatHumanReadableDuration(totalSecondsValue, intervalFormat, numberFormat) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ const formattedDuration = computed(() =>
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="text-2xs leading-tight px-0.5 py-1.5">
|
<div class="text-2xs leading-tight px-0.5 py-1">
|
||||||
<div class="font-semibold">{{ title }}</div>
|
<div class="font-semibold">{{ title }}</div>
|
||||||
<div v-if="projectName" class="font-medium opacity-90">
|
<div v-if="projectName" class="font-medium opacity-90">
|
||||||
{{ projectName }}
|
{{ projectName }}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
13
resources/js/packages/ui/src/FullCalendar/activityTypes.ts
Normal file
13
resources/js/packages/ui/src/FullCalendar/activityTypes.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export interface WindowActivityInPeriod {
|
||||||
|
appName: string;
|
||||||
|
url: string | null;
|
||||||
|
count: number;
|
||||||
|
icon?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivityPeriod {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
isIdle: boolean;
|
||||||
|
windowActivities?: WindowActivityInPeriod[];
|
||||||
|
}
|
||||||
40
resources/js/packages/ui/src/FullCalendar/calendarTypes.ts
Normal file
40
resources/js/packages/ui/src/FullCalendar/calendarTypes.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import type { TimeEntry, Project, Client, Task } from '@/packages/api/src';
|
||||||
|
import type { Dayjs } from 'dayjs';
|
||||||
|
import type { ActivityPeriod } from './activityTypes';
|
||||||
|
|
||||||
|
export const SLOT_HEIGHT = 25;
|
||||||
|
export const DRAG_THRESHOLD = 5;
|
||||||
|
export const TIME_AXIS_WIDTH = 48;
|
||||||
|
|
||||||
|
export interface CalendarEvent {
|
||||||
|
id: string;
|
||||||
|
timeEntry: TimeEntry;
|
||||||
|
project?: Project;
|
||||||
|
client?: Client;
|
||||||
|
task?: Task;
|
||||||
|
isRunning: boolean;
|
||||||
|
durationMinutes: number;
|
||||||
|
title: string;
|
||||||
|
backgroundColor: string;
|
||||||
|
borderColor: string;
|
||||||
|
dayStart: Dayjs;
|
||||||
|
dayEnd: Dayjs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DayEvent {
|
||||||
|
event: CalendarEvent;
|
||||||
|
top: number;
|
||||||
|
height: number;
|
||||||
|
left: string;
|
||||||
|
width: string;
|
||||||
|
isClippedStart: boolean;
|
||||||
|
isClippedEnd: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivityBox {
|
||||||
|
dateStr: string;
|
||||||
|
top: number;
|
||||||
|
height: number;
|
||||||
|
isIdle: boolean;
|
||||||
|
period: ActivityPeriod;
|
||||||
|
}
|
||||||
@@ -1,393 +0,0 @@
|
|||||||
import { createPlugin, type PluginDef } from '@fullcalendar/core';
|
|
||||||
import { computePosition, flip, shift, offset, autoUpdate } from '@floating-ui/dom';
|
|
||||||
|
|
||||||
export interface WindowActivityInPeriod {
|
|
||||||
appName: string;
|
|
||||||
url: string | null;
|
|
||||||
count: number;
|
|
||||||
icon?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ActivityPeriod {
|
|
||||||
start: string;
|
|
||||||
end: string;
|
|
||||||
isIdle: boolean;
|
|
||||||
windowActivities?: WindowActivityInPeriod[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ActivityStatusPluginOptions {
|
|
||||||
activityPeriods?: ActivityPeriod[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tooltip state management - single instance per module
|
|
||||||
let tooltipInstance: HTMLElement | null = null;
|
|
||||||
let cleanupAutoUpdate: (() => void) | null = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates and manages a tooltip element for activity status boxes
|
|
||||||
*/
|
|
||||||
function getOrCreateTooltip(): HTMLElement {
|
|
||||||
if (!tooltipInstance) {
|
|
||||||
tooltipInstance = document.createElement('div');
|
|
||||||
tooltipInstance.className =
|
|
||||||
'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground';
|
|
||||||
tooltipInstance.style.position = 'fixed';
|
|
||||||
tooltipInstance.style.pointerEvents = 'none';
|
|
||||||
tooltipInstance.style.opacity = '0';
|
|
||||||
tooltipInstance.style.whiteSpace = 'nowrap';
|
|
||||||
tooltipInstance.style.transform = 'scale(0.95)';
|
|
||||||
tooltipInstance.style.transition = 'opacity 150ms, transform 150ms';
|
|
||||||
document.body.appendChild(tooltipInstance);
|
|
||||||
}
|
|
||||||
return tooltipInstance;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shows tooltip for an activity status box using Floating UI's autoUpdate
|
|
||||||
*/
|
|
||||||
function showTooltip(box: HTMLElement, tooltip: HTMLElement, content: string | HTMLElement) {
|
|
||||||
// Clear previous content
|
|
||||||
tooltip.innerHTML = '';
|
|
||||||
|
|
||||||
if (typeof content === 'string') {
|
|
||||||
tooltip.textContent = content;
|
|
||||||
} else {
|
|
||||||
tooltip.appendChild(content);
|
|
||||||
}
|
|
||||||
|
|
||||||
tooltip.style.opacity = '1';
|
|
||||||
tooltip.style.transform = 'scale(1)';
|
|
||||||
|
|
||||||
// Clean up previous autoUpdate if it exists
|
|
||||||
if (cleanupAutoUpdate) {
|
|
||||||
cleanupAutoUpdate();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use autoUpdate to automatically update position
|
|
||||||
cleanupAutoUpdate = autoUpdate(box, tooltip, () => {
|
|
||||||
computePosition(box, tooltip, {
|
|
||||||
placement: 'right',
|
|
||||||
middleware: [offset(8), flip(), shift({ padding: 5 })],
|
|
||||||
}).then(({ x, y }) => {
|
|
||||||
tooltip.style.left = `${x}px`;
|
|
||||||
tooltip.style.top = `${y}px`;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hides the tooltip immediately
|
|
||||||
*/
|
|
||||||
function hideTooltip(tooltip: HTMLElement) {
|
|
||||||
tooltip.style.opacity = '0';
|
|
||||||
tooltip.style.transform = 'scale(0.95)';
|
|
||||||
|
|
||||||
// Clean up autoUpdate when tooltip is hidden
|
|
||||||
if (cleanupAutoUpdate) {
|
|
||||||
cleanupAutoUpdate();
|
|
||||||
cleanupAutoUpdate = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Formats duration in minutes to human readable format
|
|
||||||
*/
|
|
||||||
function formatDuration(durationMinutes: number): string {
|
|
||||||
const hours = Math.floor(durationMinutes / 60);
|
|
||||||
const minutes = durationMinutes % 60;
|
|
||||||
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates tooltip content for an activity period
|
|
||||||
*/
|
|
||||||
function createTooltipContent(
|
|
||||||
status: string,
|
|
||||||
durationText: string,
|
|
||||||
windowActivities?: WindowActivityInPeriod[]
|
|
||||||
): string | HTMLElement {
|
|
||||||
if (!windowActivities || windowActivities.length === 0) {
|
|
||||||
return `${status} (${durationText})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const container = document.createElement('div');
|
|
||||||
container.style.maxWidth = '300px';
|
|
||||||
|
|
||||||
// Header with status and duration
|
|
||||||
const header = document.createElement('div');
|
|
||||||
header.style.fontWeight = '600';
|
|
||||||
header.style.marginBottom = '8px';
|
|
||||||
header.textContent = `${status} (${durationText})`;
|
|
||||||
container.appendChild(header);
|
|
||||||
|
|
||||||
// Window activities list
|
|
||||||
const totalActivities = windowActivities.reduce((sum, act) => sum + act.count, 0);
|
|
||||||
|
|
||||||
// Show top 5 activities
|
|
||||||
const topActivities = windowActivities.slice(0, 5);
|
|
||||||
|
|
||||||
topActivities.forEach((activity) => {
|
|
||||||
const activityDiv = document.createElement('div');
|
|
||||||
activityDiv.style.marginTop = '4px';
|
|
||||||
activityDiv.style.fontSize = '11px';
|
|
||||||
activityDiv.style.opacity = '0.9';
|
|
||||||
activityDiv.style.display = 'flex';
|
|
||||||
activityDiv.style.alignItems = 'center';
|
|
||||||
activityDiv.style.gap = '6px';
|
|
||||||
|
|
||||||
// Add icon if available
|
|
||||||
if (activity.icon) {
|
|
||||||
const icon = document.createElement('img');
|
|
||||||
icon.src = activity.icon;
|
|
||||||
icon.alt = activity.appName;
|
|
||||||
icon.style.width = '16px';
|
|
||||||
icon.style.height = '16px';
|
|
||||||
icon.style.borderRadius = '2px';
|
|
||||||
icon.style.flexShrink = '0';
|
|
||||||
activityDiv.appendChild(icon);
|
|
||||||
} else {
|
|
||||||
// Placeholder for no icon
|
|
||||||
const placeholder = document.createElement('div');
|
|
||||||
placeholder.style.width = '16px';
|
|
||||||
placeholder.style.height = '16px';
|
|
||||||
placeholder.style.borderRadius = '2px';
|
|
||||||
placeholder.style.backgroundColor = 'rgba(255, 255, 255, 0.1)';
|
|
||||||
placeholder.style.display = 'flex';
|
|
||||||
placeholder.style.alignItems = 'center';
|
|
||||||
placeholder.style.justifyContent = 'center';
|
|
||||||
placeholder.style.fontSize = '8px';
|
|
||||||
placeholder.style.flexShrink = '0';
|
|
||||||
placeholder.textContent = activity.appName.charAt(0).toUpperCase();
|
|
||||||
activityDiv.appendChild(placeholder);
|
|
||||||
}
|
|
||||||
|
|
||||||
const textSpan = document.createElement('span');
|
|
||||||
textSpan.style.flex = '1';
|
|
||||||
textSpan.style.overflow = 'hidden';
|
|
||||||
textSpan.style.textOverflow = 'ellipsis';
|
|
||||||
textSpan.style.whiteSpace = 'nowrap';
|
|
||||||
|
|
||||||
const percentage = ((activity.count / totalActivities) * 100).toFixed(0);
|
|
||||||
const activityText = activity.url
|
|
||||||
? `${activity.appName} - ${activity.url}`
|
|
||||||
: activity.appName;
|
|
||||||
|
|
||||||
textSpan.textContent = `${percentage}% ${activityText}`;
|
|
||||||
activityDiv.appendChild(textSpan);
|
|
||||||
|
|
||||||
container.appendChild(activityDiv);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Show "and X more" if there are more activities
|
|
||||||
if (windowActivities.length > 5) {
|
|
||||||
const moreDiv = document.createElement('div');
|
|
||||||
moreDiv.style.marginTop = '4px';
|
|
||||||
moreDiv.style.fontSize = '11px';
|
|
||||||
moreDiv.style.opacity = '0.7';
|
|
||||||
moreDiv.style.fontStyle = 'italic';
|
|
||||||
moreDiv.textContent = `...and ${windowActivities.length - 5} more`;
|
|
||||||
container.appendChild(moreDiv);
|
|
||||||
}
|
|
||||||
|
|
||||||
return container;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders activity status boxes in the calendar time grid
|
|
||||||
*/
|
|
||||||
export function renderActivityStatusBoxes(
|
|
||||||
calendarEl: HTMLElement,
|
|
||||||
activityPeriods: ActivityPeriod[]
|
|
||||||
) {
|
|
||||||
if (!calendarEl) return;
|
|
||||||
|
|
||||||
// Clean up existing activity boxes
|
|
||||||
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) return;
|
|
||||||
|
|
||||||
const lanes = timeGrid.querySelectorAll('.fc-timegrid-col');
|
|
||||||
if (lanes.length === 0) return;
|
|
||||||
|
|
||||||
// Get or reuse the single tooltip instance
|
|
||||||
const tooltip = getOrCreateTooltip();
|
|
||||||
|
|
||||||
// Get slot duration from calendar (fallback to 15 minutes)
|
|
||||||
const slotDurationMinutes = getSlotDuration(calendarEl);
|
|
||||||
|
|
||||||
lanes.forEach((lane: Element) => {
|
|
||||||
// Get the date for this lane from the data attribute
|
|
||||||
const laneEl = lane as HTMLElement;
|
|
||||||
const dateStr = laneEl.getAttribute('data-date');
|
|
||||||
|
|
||||||
if (!dateStr) 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);
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate actual start and end times for this day
|
|
||||||
const actualStart = periodStart > laneDateStart ? periodStart : laneDateStart;
|
|
||||||
const actualEnd = periodEnd < laneDateEnd ? periodEnd : laneDateEnd;
|
|
||||||
|
|
||||||
// Calculate the position and height of the activity box
|
|
||||||
const { top, height } = calculateBoxPosition(
|
|
||||||
calendarEl,
|
|
||||||
actualStart,
|
|
||||||
actualEnd,
|
|
||||||
slotDurationMinutes
|
|
||||||
);
|
|
||||||
|
|
||||||
if (height <= 0) return;
|
|
||||||
|
|
||||||
hasActivityStatusForThisDay = true;
|
|
||||||
|
|
||||||
// Calculate duration in minutes
|
|
||||||
const durationMs = actualEnd.getTime() - actualStart.getTime();
|
|
||||||
const durationMinutes = Math.round(durationMs / 60000);
|
|
||||||
const durationText = formatDuration(durationMinutes);
|
|
||||||
|
|
||||||
// Add tooltip text based on status
|
|
||||||
const status = period.isIdle ? 'Idling' : 'Active';
|
|
||||||
|
|
||||||
// Create and append the activity status box
|
|
||||||
const box = document.createElement('div');
|
|
||||||
box.className = `activity-status-box ${period.isIdle ? 'idle' : 'active'}`;
|
|
||||||
box.style.top = `${top}px`;
|
|
||||||
box.style.height = `${height}px`;
|
|
||||||
|
|
||||||
// Store tooltip content generator in data attribute for event delegation
|
|
||||||
const tooltipContent = createTooltipContent(
|
|
||||||
status,
|
|
||||||
durationText,
|
|
||||||
period.windowActivities
|
|
||||||
);
|
|
||||||
|
|
||||||
// Add hover event listeners for tooltip
|
|
||||||
box.addEventListener('mouseenter', () => {
|
|
||||||
showTooltip(box, tooltip, tooltipContent);
|
|
||||||
});
|
|
||||||
|
|
||||||
box.addEventListener('mouseleave', () => {
|
|
||||||
hideTooltip(tooltip);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Position relative to the lane
|
|
||||||
const laneFrame = lane.querySelector('.fc-timegrid-col-frame');
|
|
||||||
if (laneFrame) {
|
|
||||||
laneFrame.appendChild(box);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mark this lane as having activity status if any periods were rendered
|
|
||||||
if (hasActivityStatusForThisDay) {
|
|
||||||
laneEl.classList.add('has-activity-status');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the slot duration from the calendar configuration
|
|
||||||
*/
|
|
||||||
function getSlotDuration(calendarEl: HTMLElement): number {
|
|
||||||
const slotsEl = calendarEl.querySelectorAll('.fc-timegrid-slot');
|
|
||||||
if (slotsEl.length < 2) return 15; // Default to 15 minutes
|
|
||||||
|
|
||||||
// Try to calculate from the time difference between slots
|
|
||||||
const firstSlot = slotsEl[0] as HTMLElement;
|
|
||||||
const secondSlot = slotsEl[1] as HTMLElement;
|
|
||||||
|
|
||||||
const firstTime = firstSlot.getAttribute('data-time');
|
|
||||||
const secondTime = secondSlot.getAttribute('data-time');
|
|
||||||
|
|
||||||
if (firstTime && secondTime) {
|
|
||||||
const [h1 = 0, m1 = 0] = firstTime.split(':').map(Number);
|
|
||||||
const [h2 = 0, m2 = 0] = secondTime.split(':').map(Number);
|
|
||||||
const diff = h2 * 60 + m2 - (h1 * 60 + m1);
|
|
||||||
if (diff > 0) return diff;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to 15 minutes
|
|
||||||
return 15;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculates the pixel position and height for an activity status box
|
|
||||||
*/
|
|
||||||
function calculateBoxPosition(
|
|
||||||
calendarEl: HTMLElement,
|
|
||||||
startTime: Date,
|
|
||||||
endTime: Date,
|
|
||||||
slotDurationMinutes: number
|
|
||||||
): { top: number; height: number } {
|
|
||||||
// Get the slot duration and slot height
|
|
||||||
const slotsEl = calendarEl.querySelectorAll('.fc-timegrid-slot');
|
|
||||||
if (slotsEl.length === 0) {
|
|
||||||
return { top: 0, height: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate slot height (assuming all slots are equal height)
|
|
||||||
const firstSlot = slotsEl[0] as HTMLElement;
|
|
||||||
const slotHeight = firstSlot.offsetHeight;
|
|
||||||
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cleanup function to remove tooltip from DOM
|
|
||||||
*/
|
|
||||||
export function cleanupActivityStatusPlugin() {
|
|
||||||
if (tooltipInstance) {
|
|
||||||
tooltipInstance.remove();
|
|
||||||
tooltipInstance = null;
|
|
||||||
}
|
|
||||||
if (cleanupAutoUpdate) {
|
|
||||||
cleanupAutoUpdate();
|
|
||||||
cleanupAutoUpdate = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* FullCalendar plugin to display idle/active status boxes in the time grid
|
|
||||||
*/
|
|
||||||
const activityStatusPlugin: PluginDef = createPlugin({
|
|
||||||
name: '@solidtime/activity-status',
|
|
||||||
|
|
||||||
optionRefiners: {
|
|
||||||
activityPeriods: (rawVal: unknown): ActivityPeriod[] => {
|
|
||||||
if (!Array.isArray(rawVal)) return [];
|
|
||||||
return rawVal as ActivityPeriod[];
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default activityStatusPlugin;
|
|
||||||
103
resources/js/packages/ui/src/FullCalendar/useActivityBoxes.ts
Normal file
103
resources/js/packages/ui/src/FullCalendar/useActivityBoxes.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { computed, type ComputedRef } from 'vue';
|
||||||
|
import type { Dayjs } from 'dayjs';
|
||||||
|
import type { ActivityPeriod, WindowActivityInPeriod } from './activityTypes';
|
||||||
|
import type { CalendarSettings } from './calendarSettings';
|
||||||
|
import type { ActivityBox } from './calendarTypes';
|
||||||
|
import type { Ref } from 'vue';
|
||||||
|
import { getLocalizedDayJs } from '../utils/time';
|
||||||
|
|
||||||
|
export function useActivityBoxes(params: {
|
||||||
|
activityPeriods: () => ActivityPeriod[] | undefined;
|
||||||
|
viewDays: ComputedRef<Dayjs[]>;
|
||||||
|
calendarSettings: Ref<CalendarSettings>;
|
||||||
|
minutesToPixels: (minutes: number) => number;
|
||||||
|
}) {
|
||||||
|
function formatActivityDuration(durationMinutes: number): string {
|
||||||
|
const hours = Math.floor(durationMinutes / 60);
|
||||||
|
const minutes = durationMinutes % 60;
|
||||||
|
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActivityBoxLabel(box: ActivityBox): string {
|
||||||
|
const periodStart = getLocalizedDayJs(box.period.start);
|
||||||
|
const periodEnd = getLocalizedDayJs(box.period.end);
|
||||||
|
const durationMinutes = Math.round(periodEnd.diff(periodStart, 'minute', true));
|
||||||
|
const durationText = formatActivityDuration(durationMinutes);
|
||||||
|
const status = box.isIdle ? 'Idling' : 'Active';
|
||||||
|
return `${status} (${durationText})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActivityBoxActivities(box: ActivityBox) {
|
||||||
|
return box.period.windowActivities ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActivityPercentage(count: number, total: number): string {
|
||||||
|
if (total === 0) return '0';
|
||||||
|
return ((count / total) * 100).toFixed(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActivityText(activity: WindowActivityInPeriod): string {
|
||||||
|
return activity.url ? `${activity.appName} - ${activity.url}` : activity.appName;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activityBoxes = computed<ActivityBox[]>(() => {
|
||||||
|
const periods = params.activityPeriods();
|
||||||
|
if (!periods || periods.length === 0) return [];
|
||||||
|
|
||||||
|
const s = params.calendarSettings.value;
|
||||||
|
const startMin = s.startHour * 60;
|
||||||
|
const endMin = s.endHour * 60;
|
||||||
|
const boxes: ActivityBox[] = [];
|
||||||
|
|
||||||
|
for (const day of params.viewDays.value) {
|
||||||
|
const dateStr = day.format('YYYY-MM-DD');
|
||||||
|
const dayStart = day.startOf('day');
|
||||||
|
const dayEnd = day.endOf('day');
|
||||||
|
|
||||||
|
for (const period of periods) {
|
||||||
|
const periodStart = getLocalizedDayJs(period.start);
|
||||||
|
const periodEnd = getLocalizedDayJs(period.end);
|
||||||
|
|
||||||
|
if (periodEnd.isBefore(dayStart) || periodStart.isAfter(dayEnd)) continue;
|
||||||
|
|
||||||
|
const actualStart = periodStart.isAfter(dayStart) ? periodStart : dayStart;
|
||||||
|
const actualEnd = periodEnd.isBefore(dayEnd) ? periodEnd : dayEnd;
|
||||||
|
|
||||||
|
const actualStartMin = actualStart.hour() * 60 + actualStart.minute();
|
||||||
|
const actualEndMin = actualEnd.hour() * 60 + actualEnd.minute();
|
||||||
|
|
||||||
|
const clampedStart = Math.max(actualStartMin, startMin);
|
||||||
|
const clampedEnd = Math.min(actualEndMin, endMin);
|
||||||
|
|
||||||
|
if (clampedEnd <= clampedStart) continue;
|
||||||
|
|
||||||
|
const top = params.minutesToPixels(clampedStart - startMin);
|
||||||
|
const height = params.minutesToPixels(clampedEnd - clampedStart);
|
||||||
|
|
||||||
|
if (height > 0) {
|
||||||
|
boxes.push({ dateStr, top, height, isIdle: period.isIdle, period });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return boxes;
|
||||||
|
});
|
||||||
|
|
||||||
|
function activityBoxesForDay(dateStr: string): ActivityBox[] {
|
||||||
|
return activityBoxes.value.filter((b) => b.dateStr === dateStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dayHasActivityStatus(dateStr: string): boolean {
|
||||||
|
return activityBoxes.value.some((b) => b.dateStr === dateStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
activityBoxes,
|
||||||
|
activityBoxesForDay,
|
||||||
|
dayHasActivityStatus,
|
||||||
|
getActivityBoxLabel,
|
||||||
|
getActivityBoxActivities,
|
||||||
|
getActivityPercentage,
|
||||||
|
getActivityText,
|
||||||
|
};
|
||||||
|
}
|
||||||
300
resources/js/packages/ui/src/FullCalendar/useCalendarEvents.ts
Normal file
300
resources/js/packages/ui/src/FullCalendar/useCalendarEvents.ts
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
import { computed, ref, type Ref, type ComputedRef } from 'vue';
|
||||||
|
import chroma from 'chroma-js';
|
||||||
|
import type { Dayjs } from 'dayjs';
|
||||||
|
import type { TimeEntry, Project, Client, Task } from '@/packages/api/src';
|
||||||
|
import { getDayJsInstance, getLocalizedDayJs } from '../utils/time';
|
||||||
|
import type { CalendarSettings } from './calendarSettings';
|
||||||
|
import type { CalendarEvent, DayEvent } from './calendarTypes';
|
||||||
|
|
||||||
|
interface PositionedEvent {
|
||||||
|
event: CalendarEvent;
|
||||||
|
startMin: number;
|
||||||
|
endMin: number;
|
||||||
|
isClippedStart: boolean;
|
||||||
|
isClippedEnd: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ColumnAssignment extends PositionedEvent {
|
||||||
|
col: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clip an event's time range to a single day and the visible hour range. */
|
||||||
|
function clipEventToDay(
|
||||||
|
ev: CalendarEvent,
|
||||||
|
dayStart: Dayjs,
|
||||||
|
dayEnd: Dayjs,
|
||||||
|
visibleStartMin: number,
|
||||||
|
visibleEndMin: number,
|
||||||
|
timeToMinutesFromMidnight: (time: Dayjs) => number
|
||||||
|
): PositionedEvent {
|
||||||
|
const isClippedStart = ev.dayStart.isBefore(dayStart);
|
||||||
|
const isClippedEnd = ev.dayEnd.isAfter(dayEnd);
|
||||||
|
|
||||||
|
let evStartMin = isClippedStart ? 0 : timeToMinutesFromMidnight(ev.dayStart);
|
||||||
|
let evEndMin = isClippedEnd ? 24 * 60 : timeToMinutesFromMidnight(ev.dayEnd);
|
||||||
|
|
||||||
|
evStartMin = Math.max(evStartMin, visibleStartMin);
|
||||||
|
evEndMin = Math.min(evEndMin, visibleEndMin);
|
||||||
|
|
||||||
|
if (evEndMin <= evStartMin) {
|
||||||
|
evEndMin = evStartMin + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { event: ev, startMin: evStartMin, endMin: evEndMin, isClippedStart, isClippedEnd };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Greedily assign each event to the first column where it fits without overlap. */
|
||||||
|
function assignColumns(positioned: PositionedEvent[]): ColumnAssignment[] {
|
||||||
|
const columns: PositionedEvent[][] = [];
|
||||||
|
const result: ColumnAssignment[] = [];
|
||||||
|
|
||||||
|
for (const item of positioned) {
|
||||||
|
let placed = false;
|
||||||
|
for (let c = 0; c < columns.length; c++) {
|
||||||
|
const lastInCol = columns[c]![columns[c]!.length - 1]!;
|
||||||
|
if (lastInCol.endMin <= item.startMin) {
|
||||||
|
columns[c]!.push(item);
|
||||||
|
result.push({ ...item, col: c });
|
||||||
|
placed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!placed) {
|
||||||
|
columns.push([item]);
|
||||||
|
result.push({ ...item, col: columns.length - 1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert column-assigned groups into pixel-positioned DayEvent objects. */
|
||||||
|
function groupsToDayEvents(
|
||||||
|
groups: { items: ColumnAssignment[]; totalCols: number }[],
|
||||||
|
visibleStartMin: number,
|
||||||
|
minutesToPixels: (minutes: number) => number
|
||||||
|
): DayEvent[] {
|
||||||
|
const result: DayEvent[] = [];
|
||||||
|
for (const group of groups) {
|
||||||
|
for (const item of group.items) {
|
||||||
|
const top = minutesToPixels(item.startMin - visibleStartMin);
|
||||||
|
const height = minutesToPixels(item.endMin - item.startMin);
|
||||||
|
result.push({
|
||||||
|
event: item.event,
|
||||||
|
top,
|
||||||
|
height: Math.max(height, 1),
|
||||||
|
left: `${(item.col / group.totalCols) * 100}%`,
|
||||||
|
width: `${(1 / group.totalCols) * 100}%`,
|
||||||
|
isClippedStart: item.isClippedStart,
|
||||||
|
isClippedEnd: item.isClippedEnd,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compute positioned events for a single day. */
|
||||||
|
function layoutDayEvents(
|
||||||
|
dayEvents: CalendarEvent[],
|
||||||
|
dayStart: Dayjs,
|
||||||
|
dayEnd: Dayjs,
|
||||||
|
visibleStartMin: number,
|
||||||
|
visibleEndMin: number,
|
||||||
|
timeToMinutesFromMidnight: (time: Dayjs) => number,
|
||||||
|
minutesToPixels: (minutes: number) => number
|
||||||
|
): DayEvent[] {
|
||||||
|
const positioned = dayEvents.map((ev) =>
|
||||||
|
clipEventToDay(
|
||||||
|
ev,
|
||||||
|
dayStart,
|
||||||
|
dayEnd,
|
||||||
|
visibleStartMin,
|
||||||
|
visibleEndMin,
|
||||||
|
timeToMinutesFromMidnight
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sort: earliest start first, then longest duration first (for stable column assignment)
|
||||||
|
positioned.sort((a, b) => {
|
||||||
|
if (a.startMin !== b.startMin) return a.startMin - b.startMin;
|
||||||
|
return b.endMin - b.startMin - (a.endMin - a.startMin);
|
||||||
|
});
|
||||||
|
|
||||||
|
const eventColumns = assignColumns(positioned);
|
||||||
|
const groups = groupOverlappingEvents(eventColumns);
|
||||||
|
return groupsToDayEvents(groups, visibleStartMin, minutesToPixels);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Group events that transitively overlap so each group shares column count. */
|
||||||
|
function groupOverlappingEvents(
|
||||||
|
eventColumns: ColumnAssignment[]
|
||||||
|
): { items: ColumnAssignment[]; totalCols: number }[] {
|
||||||
|
const groups: { items: ColumnAssignment[]; totalCols: number }[] = [];
|
||||||
|
const assigned = new Set<number>();
|
||||||
|
|
||||||
|
for (let i = 0; i < eventColumns.length; i++) {
|
||||||
|
if (assigned.has(i)) continue;
|
||||||
|
|
||||||
|
const group = [eventColumns[i]!];
|
||||||
|
assigned.add(i);
|
||||||
|
|
||||||
|
let expanded = true;
|
||||||
|
while (expanded) {
|
||||||
|
expanded = false;
|
||||||
|
for (let j = 0; j < eventColumns.length; j++) {
|
||||||
|
if (assigned.has(j)) continue;
|
||||||
|
const candidate = eventColumns[j]!;
|
||||||
|
for (const member of group) {
|
||||||
|
if (candidate.startMin < member.endMin && candidate.endMin > member.startMin) {
|
||||||
|
group.push(candidate);
|
||||||
|
assigned.add(j);
|
||||||
|
expanded = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let maxCol = 0;
|
||||||
|
for (const item of group) {
|
||||||
|
if (item.col > maxCol) maxCol = item.col;
|
||||||
|
}
|
||||||
|
groups.push({ items: group, totalCols: maxCol + 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCalendarEvents(params: {
|
||||||
|
timeEntries: () => TimeEntry[];
|
||||||
|
projects: () => Project[];
|
||||||
|
clients: () => Client[];
|
||||||
|
tasks: () => Task[];
|
||||||
|
calendarSettings: Ref<CalendarSettings>;
|
||||||
|
viewDays: ComputedRef<Dayjs[]>;
|
||||||
|
currentTime: Ref<Dayjs>;
|
||||||
|
cssBackground: Ref<string>;
|
||||||
|
minutesToPixels: (minutes: number) => number;
|
||||||
|
timeToMinutesFromMidnight: (time: Dayjs) => number;
|
||||||
|
}) {
|
||||||
|
const optimisticOverrides = ref<Map<string, TimeEntry>>(new Map());
|
||||||
|
|
||||||
|
const calendarEvents = computed<CalendarEvent[]>(() => {
|
||||||
|
const themeBackground = params.cssBackground.value?.trim();
|
||||||
|
return params.timeEntries().map((rawEntry) => {
|
||||||
|
const timeEntry = optimisticOverrides.value.get(rawEntry.id) || rawEntry;
|
||||||
|
const isRunning = timeEntry.end === null;
|
||||||
|
const project = params.projects().find((p) => p.id === timeEntry.project_id);
|
||||||
|
const client = params.clients().find((c) => c.id === project?.client_id);
|
||||||
|
const task = params.tasks().find((t) => t.id === timeEntry.task_id);
|
||||||
|
|
||||||
|
const effectiveEnd = isRunning
|
||||||
|
? params.currentTime.value
|
||||||
|
: getDayJsInstance()(timeEntry.end!);
|
||||||
|
const durationMinutes = effectiveEnd.diff(
|
||||||
|
getDayJsInstance()(timeEntry.start),
|
||||||
|
'minutes'
|
||||||
|
);
|
||||||
|
|
||||||
|
const title = timeEntry.description || 'No description';
|
||||||
|
const baseColor = project?.color || '#6B7280';
|
||||||
|
const backgroundColor = chroma.mix(baseColor, themeBackground, 0.65, 'lab').hex();
|
||||||
|
const borderColor = chroma.mix(baseColor, themeBackground, 0.5, 'lab').hex();
|
||||||
|
|
||||||
|
const startTime = getLocalizedDayJs(timeEntry.start);
|
||||||
|
const endTime = isRunning
|
||||||
|
? getLocalizedDayJs(params.currentTime.value.toISOString())
|
||||||
|
: durationMinutes === 0
|
||||||
|
? startTime.add(1, 'second')
|
||||||
|
: getLocalizedDayJs(timeEntry.end!);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: timeEntry.id,
|
||||||
|
timeEntry,
|
||||||
|
project,
|
||||||
|
client,
|
||||||
|
task,
|
||||||
|
isRunning,
|
||||||
|
durationMinutes,
|
||||||
|
title,
|
||||||
|
backgroundColor,
|
||||||
|
borderColor,
|
||||||
|
dayStart: startTime,
|
||||||
|
dayEnd: endTime,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const eventsByDay = computed(() => {
|
||||||
|
const s = params.calendarSettings.value;
|
||||||
|
const visibleStartMin = s.startHour * 60;
|
||||||
|
const visibleEndMin = s.endHour * 60;
|
||||||
|
const result: Record<string, DayEvent[]> = {};
|
||||||
|
|
||||||
|
for (const day of params.viewDays.value) {
|
||||||
|
const dayStart = day.startOf('day');
|
||||||
|
const dayEnd = day.endOf('day');
|
||||||
|
|
||||||
|
const dayEvents = calendarEvents.value.filter(
|
||||||
|
(ev) => ev.dayStart.isBefore(dayEnd) && ev.dayEnd.isAfter(dayStart)
|
||||||
|
);
|
||||||
|
|
||||||
|
result[day.format('YYYY-MM-DD')] = layoutDayEvents(
|
||||||
|
dayEvents,
|
||||||
|
dayStart,
|
||||||
|
dayEnd,
|
||||||
|
visibleStartMin,
|
||||||
|
visibleEndMin,
|
||||||
|
params.timeToMinutesFromMidnight,
|
||||||
|
params.minutesToPixels
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
const dailyTotals = computed(() => {
|
||||||
|
const totals: Record<string, number> = {};
|
||||||
|
params.timeEntries().forEach((entry) => {
|
||||||
|
const date = getLocalizedDayJs(entry.start).format('YYYY-MM-DD');
|
||||||
|
let durationSeconds: number;
|
||||||
|
|
||||||
|
if (entry.end !== null) {
|
||||||
|
durationSeconds = getDayJsInstance()(entry.end).diff(
|
||||||
|
getDayJsInstance()(entry.start),
|
||||||
|
'seconds'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
durationSeconds = params.currentTime.value.diff(
|
||||||
|
getDayJsInstance()(entry.start),
|
||||||
|
'seconds'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
totals[date] = (totals[date] || 0) + durationSeconds;
|
||||||
|
});
|
||||||
|
return totals;
|
||||||
|
});
|
||||||
|
|
||||||
|
function isToday(day: Dayjs): boolean {
|
||||||
|
return day.isSame(getLocalizedDayJs(), 'day');
|
||||||
|
}
|
||||||
|
|
||||||
|
const nowIndicatorTop = computed(() => {
|
||||||
|
const s = params.calendarSettings.value;
|
||||||
|
const now = getLocalizedDayJs(params.currentTime.value.toISOString());
|
||||||
|
const minutesFromMidnight = now.hour() * 60 + now.minute();
|
||||||
|
const startMin = s.startHour * 60;
|
||||||
|
if (minutesFromMidnight < startMin || minutesFromMidnight >= s.endHour * 60) return -1;
|
||||||
|
return params.minutesToPixels(minutesFromMidnight - startMin);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
optimisticOverrides,
|
||||||
|
calendarEvents,
|
||||||
|
eventsByDay,
|
||||||
|
dailyTotals,
|
||||||
|
isToday,
|
||||||
|
nowIndicatorTop,
|
||||||
|
};
|
||||||
|
}
|
||||||
137
resources/js/packages/ui/src/FullCalendar/useCalendarGrid.ts
Normal file
137
resources/js/packages/ui/src/FullCalendar/useCalendarGrid.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { computed, type Ref } from 'vue';
|
||||||
|
import type { ComputedRef } from 'vue';
|
||||||
|
import type { Dayjs } from 'dayjs';
|
||||||
|
import type { Organization } from '@/packages/api/src';
|
||||||
|
import type { CalendarSettings } from './calendarSettings';
|
||||||
|
import { SLOT_HEIGHT } from './calendarTypes';
|
||||||
|
|
||||||
|
export function useCalendarGrid(
|
||||||
|
calendarSettings: Ref<CalendarSettings>,
|
||||||
|
organization: ComputedRef<Organization> | undefined,
|
||||||
|
scrollerRef: Ref<HTMLElement | null>,
|
||||||
|
rootRef: Ref<HTMLElement | null>
|
||||||
|
) {
|
||||||
|
const slots = computed(() => {
|
||||||
|
const s = calendarSettings.value;
|
||||||
|
const result: { time: string; isHour: boolean; minutes: number }[] = [];
|
||||||
|
const startMin = s.startHour * 60;
|
||||||
|
const endMin = s.endHour * 60;
|
||||||
|
|
||||||
|
for (let m = startMin; m < endMin; m += s.slotMinutes) {
|
||||||
|
const hours = Math.floor(m / 60);
|
||||||
|
const mins = m % 60;
|
||||||
|
const time = `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}:00`;
|
||||||
|
const isHour = mins === 0;
|
||||||
|
result.push({ time, isHour, minutes: m });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalGridHeight = computed(() => slots.value.length * SLOT_HEIGHT);
|
||||||
|
|
||||||
|
function formatSlotLabel(hour: number): string {
|
||||||
|
const timeFormat = organization?.value?.time_format || '24-hours';
|
||||||
|
|
||||||
|
if (timeFormat === '12-hours') {
|
||||||
|
const period = hour >= 12 ? 'PM' : 'AM';
|
||||||
|
const h = hour % 12 || 12;
|
||||||
|
return `${h} ${period}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${String(hour).padStart(2, '0')}:00`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function minutesToPixels(minutes: number): number {
|
||||||
|
const s = calendarSettings.value;
|
||||||
|
return (minutes / s.slotMinutes) * SLOT_HEIGHT;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pixelsToMinutesFromMidnight(px: number): number {
|
||||||
|
const s = calendarSettings.value;
|
||||||
|
return (px / SLOT_HEIGHT) * s.slotMinutes + s.startHour * 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeToMinutesFromMidnight(time: Dayjs): number {
|
||||||
|
return time.hour() * 60 + time.minute() + time.second() / 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapStartToGrid(time: Dayjs, snapMinutes: number): Dayjs {
|
||||||
|
const minutes = time.hour() * 60 + time.minute();
|
||||||
|
const snapped = Math.floor(minutes / snapMinutes) * snapMinutes;
|
||||||
|
return time.startOf('day').add(snapped, 'minute');
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapEndToGrid(time: Dayjs, snapMinutes: number): Dayjs {
|
||||||
|
const minutes = time.hour() * 60 + time.minute();
|
||||||
|
const snapped = Math.ceil(minutes / snapMinutes) * snapMinutes;
|
||||||
|
return time.startOf('day').add(snapped, 'minute');
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapToNearestGrid(time: Dayjs, snapMinutes: number): Dayjs {
|
||||||
|
const minutes = time.hour() * 60 + time.minute();
|
||||||
|
const snapped = Math.round(minutes / snapMinutes) * snapMinutes;
|
||||||
|
return time.startOf('day').add(snapped, 'minute');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDayColumnBounds(): { dateStr: string; left: number; right: number }[] {
|
||||||
|
if (!rootRef.value) return [];
|
||||||
|
const cols = rootRef.value.querySelectorAll<HTMLElement>('.fc-timegrid-col');
|
||||||
|
const bounds: { dateStr: string; left: number; right: number }[] = [];
|
||||||
|
cols.forEach((col) => {
|
||||||
|
const rect = col.getBoundingClientRect();
|
||||||
|
bounds.push({
|
||||||
|
dateStr: col.dataset.date || '',
|
||||||
|
left: rect.left,
|
||||||
|
right: rect.right,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return bounds;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDayFromClientX(clientX: number): string | null {
|
||||||
|
const bounds = getDayColumnBounds();
|
||||||
|
for (const b of bounds) {
|
||||||
|
if (clientX >= b.left && clientX < b.right) {
|
||||||
|
return b.dateStr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let closest: string | null = null;
|
||||||
|
let minDist = Infinity;
|
||||||
|
for (const b of bounds) {
|
||||||
|
const center = (b.left + b.right) / 2;
|
||||||
|
const dist = Math.abs(clientX - center);
|
||||||
|
if (dist < minDist) {
|
||||||
|
minDist = dist;
|
||||||
|
closest = b.dateStr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return closest;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getScrollerTop(): number {
|
||||||
|
if (!scrollerRef.value) return 0;
|
||||||
|
return scrollerRef.value.getBoundingClientRect().top + scrollerRef.value.scrollTop;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clientYToGridPixels(clientY: number): number {
|
||||||
|
if (!scrollerRef.value) return 0;
|
||||||
|
const scrollerRect = scrollerRef.value.getBoundingClientRect();
|
||||||
|
return clientY - scrollerRect.top + scrollerRef.value.scrollTop;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
slots,
|
||||||
|
totalGridHeight,
|
||||||
|
formatSlotLabel,
|
||||||
|
minutesToPixels,
|
||||||
|
pixelsToMinutesFromMidnight,
|
||||||
|
timeToMinutesFromMidnight,
|
||||||
|
snapStartToGrid,
|
||||||
|
snapEndToGrid,
|
||||||
|
snapToNearestGrid,
|
||||||
|
getDayColumnBounds,
|
||||||
|
getDayFromClientX,
|
||||||
|
getScrollerTop,
|
||||||
|
clientYToGridPixels,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import type { Dayjs } from 'dayjs';
|
||||||
|
import { getLocalizedDayJs } from '../utils/time';
|
||||||
|
import { getWeekStart } from '../utils/settings';
|
||||||
|
|
||||||
|
export function useCalendarNavigation(callbacks: {
|
||||||
|
onDatesChange: (payload: { start: Date; end: Date }) => void;
|
||||||
|
scrollToCurrentTime: () => void;
|
||||||
|
}) {
|
||||||
|
const activeView = ref('timeGridWeek');
|
||||||
|
const currentDate = ref(getLocalizedDayJs());
|
||||||
|
|
||||||
|
function getFirstDay(): number {
|
||||||
|
const weekStart = getWeekStart();
|
||||||
|
const weekStartMap: Record<string, number> = {
|
||||||
|
sunday: 0,
|
||||||
|
monday: 1,
|
||||||
|
tuesday: 2,
|
||||||
|
wednesday: 3,
|
||||||
|
thursday: 4,
|
||||||
|
friday: 5,
|
||||||
|
saturday: 6,
|
||||||
|
};
|
||||||
|
return weekStartMap[weekStart] ?? 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewDays = computed<Dayjs[]>(() => {
|
||||||
|
const numDays = activeView.value === 'timeGridWeek' ? 7 : 1;
|
||||||
|
|
||||||
|
if (numDays === 1) {
|
||||||
|
return [currentDate.value.startOf('day')];
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstDay = getFirstDay();
|
||||||
|
const today = currentDate.value.startOf('day');
|
||||||
|
const offset = (today.day() - firstDay + 7) % 7;
|
||||||
|
const weekStart = today.subtract(offset, 'day');
|
||||||
|
|
||||||
|
const days: Dayjs[] = [];
|
||||||
|
for (let i = 0; i < numDays; i++) {
|
||||||
|
days.push(weekStart.add(i, 'day'));
|
||||||
|
}
|
||||||
|
return days;
|
||||||
|
});
|
||||||
|
|
||||||
|
const viewTitle = computed(() => {
|
||||||
|
if (activeView.value === 'timeGridDay') {
|
||||||
|
return currentDate.value.format('MMMM YYYY');
|
||||||
|
}
|
||||||
|
|
||||||
|
const days = viewDays.value;
|
||||||
|
if (days.length === 0) return '';
|
||||||
|
|
||||||
|
const first = days[0]!;
|
||||||
|
const last = days[days.length - 1]!;
|
||||||
|
|
||||||
|
if (first.year() !== last.year()) {
|
||||||
|
return `${first.format('MMM YYYY')} \u2013 ${last.format('MMM YYYY')}`;
|
||||||
|
}
|
||||||
|
if (first.month() !== last.month()) {
|
||||||
|
return `${first.format('MMM')} \u2013 ${last.format('MMM YYYY')}`;
|
||||||
|
}
|
||||||
|
return first.format('MMMM YYYY');
|
||||||
|
});
|
||||||
|
|
||||||
|
function emitDatesChange() {
|
||||||
|
const days = viewDays.value;
|
||||||
|
if (days.length === 0) return;
|
||||||
|
|
||||||
|
const start = days[0]!.toDate();
|
||||||
|
const end = days[days.length - 1]!.add(1, 'day').toDate();
|
||||||
|
callbacks.onDatesChange({ start, end });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePrev() {
|
||||||
|
if (activeView.value === 'timeGridWeek') {
|
||||||
|
currentDate.value = currentDate.value.subtract(7, 'day');
|
||||||
|
} else {
|
||||||
|
currentDate.value = currentDate.value.subtract(1, 'day');
|
||||||
|
}
|
||||||
|
emitDatesChange();
|
||||||
|
callbacks.scrollToCurrentTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNext() {
|
||||||
|
if (activeView.value === 'timeGridWeek') {
|
||||||
|
currentDate.value = currentDate.value.add(7, 'day');
|
||||||
|
} else {
|
||||||
|
currentDate.value = currentDate.value.add(1, 'day');
|
||||||
|
}
|
||||||
|
emitDatesChange();
|
||||||
|
callbacks.scrollToCurrentTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleToday() {
|
||||||
|
currentDate.value = getLocalizedDayJs();
|
||||||
|
emitDatesChange();
|
||||||
|
callbacks.scrollToCurrentTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChangeView(view: string) {
|
||||||
|
activeView.value = view;
|
||||||
|
emitDatesChange();
|
||||||
|
callbacks.scrollToCurrentTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeView,
|
||||||
|
currentDate,
|
||||||
|
viewDays,
|
||||||
|
viewTitle,
|
||||||
|
emitDatesChange,
|
||||||
|
handlePrev,
|
||||||
|
handleNext,
|
||||||
|
handleToday,
|
||||||
|
handleChangeView,
|
||||||
|
};
|
||||||
|
}
|
||||||
172
resources/js/packages/ui/src/FullCalendar/useContextMenu.ts
Normal file
172
resources/js/packages/ui/src/FullCalendar/useContextMenu.ts
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import { ref, type Ref, type ComputedRef } from 'vue';
|
||||||
|
import type { Dayjs } from 'dayjs';
|
||||||
|
import type { TimeEntry } from '@/packages/api/src';
|
||||||
|
import { getDayJsInstance } from '../utils/time';
|
||||||
|
import { getUserTimezone } from '../utils/settings';
|
||||||
|
import type { CalendarSettings } from './calendarSettings';
|
||||||
|
import type { CalendarEvent } from './calendarTypes';
|
||||||
|
|
||||||
|
export function useContextMenu(params: {
|
||||||
|
calendarSettings: Ref<CalendarSettings>;
|
||||||
|
calendarEvents: ComputedRef<CalendarEvent[]>;
|
||||||
|
pixelsToMinutesFromMidnight: (px: number) => number;
|
||||||
|
getDayFromClientX: (clientX: number) => string | null;
|
||||||
|
clientYToGridPixels: (clientY: number) => number;
|
||||||
|
createTimeEntry: (
|
||||||
|
entry: Omit<TimeEntry, 'id' | 'organization_id' | 'user_id'>
|
||||||
|
) => Promise<void>;
|
||||||
|
updateTimeEntry: (entry: TimeEntry) => Promise<void>;
|
||||||
|
deleteTimeEntry: (id: string) => Promise<void>;
|
||||||
|
onEditEvent: (entry: TimeEntry) => void;
|
||||||
|
onCreateEvent: (start: Dayjs, end: Dayjs) => void;
|
||||||
|
emitRefresh: () => void;
|
||||||
|
}) {
|
||||||
|
const contextMenuTimeEntry = ref<TimeEntry | null>(null);
|
||||||
|
const contextMenuCreateTime = ref<{ start: Dayjs; end: Dayjs } | null>(null);
|
||||||
|
|
||||||
|
function getTimeAtClickPosition(event: MouseEvent): { start: Dayjs; end: Dayjs } | null {
|
||||||
|
const date = params.getDayFromClientX(event.clientX);
|
||||||
|
if (!date) return null;
|
||||||
|
|
||||||
|
const gridY = params.clientYToGridPixels(event.clientY);
|
||||||
|
const minutesFromGridStart = params.pixelsToMinutesFromMidnight(gridY);
|
||||||
|
|
||||||
|
const snap = params.calendarSettings.value.snapMinutes;
|
||||||
|
const snappedMinutes = Math.floor(minutesFromGridStart / snap) * snap;
|
||||||
|
|
||||||
|
const dayjs = getDayJsInstance();
|
||||||
|
const startLocal = dayjs(`${date}T00:00:00`)
|
||||||
|
.tz(getUserTimezone(), true)
|
||||||
|
.add(snappedMinutes, 'minute');
|
||||||
|
const snappedEnd = startLocal.add(snap, 'minute');
|
||||||
|
|
||||||
|
return { start: startLocal.utc(), end: snappedEnd.utc() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCalendarContextMenu(event: MouseEvent) {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
const eventEl = target.closest<HTMLElement>('[data-event-id]');
|
||||||
|
|
||||||
|
if (!eventEl) {
|
||||||
|
contextMenuTimeEntry.value = null;
|
||||||
|
const timeInfo = getTimeAtClickPosition(event);
|
||||||
|
contextMenuCreateTime.value = timeInfo;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventId = eventEl.getAttribute('data-event-id');
|
||||||
|
if (!eventId) return;
|
||||||
|
|
||||||
|
const ev = params.calendarEvents.value.find((e) => e.id === eventId);
|
||||||
|
if (!ev) return;
|
||||||
|
|
||||||
|
contextMenuTimeEntry.value = ev.timeEntry;
|
||||||
|
contextMenuCreateTime.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleContextEdit() {
|
||||||
|
if (!contextMenuTimeEntry.value || contextMenuTimeEntry.value.end === null) return;
|
||||||
|
params.onEditEvent(contextMenuTimeEntry.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleContextDuplicate() {
|
||||||
|
if (!contextMenuTimeEntry.value || contextMenuTimeEntry.value.end === null) return;
|
||||||
|
const entry = contextMenuTimeEntry.value;
|
||||||
|
await params.createTimeEntry({
|
||||||
|
start: entry.start,
|
||||||
|
end: entry.end,
|
||||||
|
billable: entry.billable,
|
||||||
|
description: entry.description,
|
||||||
|
project_id: entry.project_id,
|
||||||
|
task_id: entry.task_id,
|
||||||
|
tags: entry.tags,
|
||||||
|
});
|
||||||
|
params.emitRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleContextDelete() {
|
||||||
|
if (!contextMenuTimeEntry.value || contextMenuTimeEntry.value.end === null) return;
|
||||||
|
await params.deleteTimeEntry(contextMenuTimeEntry.value.id);
|
||||||
|
params.emitRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleContextSplit() {
|
||||||
|
if (!contextMenuTimeEntry.value || contextMenuTimeEntry.value.end === null) return;
|
||||||
|
const entry = contextMenuTimeEntry.value;
|
||||||
|
if (!entry.end) return;
|
||||||
|
const start = getDayJsInstance()(entry.start);
|
||||||
|
const end = getDayJsInstance()(entry.end);
|
||||||
|
const midpoint = start.add(end.diff(start) / 2, 'millisecond').startOf('minute');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await params.updateTimeEntry({ ...entry, end: midpoint.utc().format() });
|
||||||
|
} catch {
|
||||||
|
// Update failed, don't proceed with create
|
||||||
|
params.emitRefresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await params.createTimeEntry({
|
||||||
|
start: midpoint.utc().format(),
|
||||||
|
end: entry.end,
|
||||||
|
billable: entry.billable,
|
||||||
|
description: entry.description,
|
||||||
|
project_id: entry.project_id,
|
||||||
|
task_id: entry.task_id,
|
||||||
|
tags: entry.tags,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Create failed after update succeeded — restore original entry
|
||||||
|
try {
|
||||||
|
await params.updateTimeEntry({ ...entry });
|
||||||
|
} catch {
|
||||||
|
// Restoration also failed; refresh will show server state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
params.emitRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleContextStop() {
|
||||||
|
if (!contextMenuTimeEntry.value || contextMenuTimeEntry.value.end !== null) return;
|
||||||
|
const entry = contextMenuTimeEntry.value;
|
||||||
|
await params.updateTimeEntry({
|
||||||
|
...entry,
|
||||||
|
end: getDayJsInstance()().utc().format(),
|
||||||
|
});
|
||||||
|
params.emitRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleContextDiscard() {
|
||||||
|
if (!contextMenuTimeEntry.value || contextMenuTimeEntry.value.end !== null) return;
|
||||||
|
await params.deleteTimeEntry(contextMenuTimeEntry.value.id);
|
||||||
|
params.emitRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleContextCreate() {
|
||||||
|
if (contextMenuCreateTime.value) {
|
||||||
|
params.onCreateEvent(
|
||||||
|
contextMenuCreateTime.value.start,
|
||||||
|
contextMenuCreateTime.value.end
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
params.onCreateEvent(
|
||||||
|
getDayJsInstance()().utc(),
|
||||||
|
getDayJsInstance()().utc().add(1, 'hour')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
contextMenuTimeEntry,
|
||||||
|
contextMenuCreateTime,
|
||||||
|
handleCalendarContextMenu,
|
||||||
|
handleContextEdit,
|
||||||
|
handleContextDuplicate,
|
||||||
|
handleContextDelete,
|
||||||
|
handleContextSplit,
|
||||||
|
handleContextStop,
|
||||||
|
handleContextDiscard,
|
||||||
|
handleContextCreate,
|
||||||
|
};
|
||||||
|
}
|
||||||
295
resources/js/packages/ui/src/FullCalendar/useEventDrag.ts
Normal file
295
resources/js/packages/ui/src/FullCalendar/useEventDrag.ts
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
import { computed, ref, onUnmounted, type Ref, type ComputedRef } from 'vue';
|
||||||
|
import type { Dayjs } from 'dayjs';
|
||||||
|
import type { TimeEntry } from '@/packages/api/src';
|
||||||
|
import { getDayJsInstance, getLocalizedDayJs } from '../utils/time';
|
||||||
|
import { getUserTimezone } from '../utils/settings';
|
||||||
|
import type { CalendarSettings } from './calendarSettings';
|
||||||
|
import type { CalendarEvent, DayEvent } from './calendarTypes';
|
||||||
|
import { SLOT_HEIGHT, DRAG_THRESHOLD } from './calendarTypes';
|
||||||
|
|
||||||
|
export function useEventDrag(params: {
|
||||||
|
calendarSettings: Ref<CalendarSettings>;
|
||||||
|
viewDays: ComputedRef<Dayjs[]>;
|
||||||
|
optimisticOverrides: Ref<Map<string, TimeEntry>>;
|
||||||
|
updateTimeEntry: (entry: TimeEntry) => Promise<void>;
|
||||||
|
emitRefresh: () => void;
|
||||||
|
minutesToPixels: (minutes: number) => number;
|
||||||
|
pixelsToMinutesFromMidnight: (px: number) => number;
|
||||||
|
getDayFromClientX: (clientX: number) => string | null;
|
||||||
|
clientYToGridPixels: (clientY: number) => number;
|
||||||
|
onClickEvent: (ev: CalendarEvent) => void;
|
||||||
|
}) {
|
||||||
|
const isDragging = ref(false);
|
||||||
|
const dragEventId = ref<string | null>(null);
|
||||||
|
const dragOffsetMinutes = ref(0);
|
||||||
|
const dragCurrentTop = ref(0);
|
||||||
|
const dragCurrentDay = ref<string | null>(null);
|
||||||
|
const dragOriginalDayStr = ref<string | null>(null);
|
||||||
|
const dragOriginalHeight = ref(0);
|
||||||
|
const dragVisibleDurationMinutes = ref(0);
|
||||||
|
|
||||||
|
// Non-reactive state
|
||||||
|
let dragStartClientX = 0;
|
||||||
|
let dragStartClientY = 0;
|
||||||
|
let dragStartEventTop = 0;
|
||||||
|
let dragOriginalEvent: CalendarEvent | null = null;
|
||||||
|
let dragFullDurationMinutes = 0;
|
||||||
|
let dragEventStartOffsetMinutes = 0;
|
||||||
|
let hasMoved = false;
|
||||||
|
|
||||||
|
function onEventPointerDown(e: PointerEvent, ev: CalendarEvent, dayEvent: DayEvent) {
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.closest('.fc-event-resizer')) return;
|
||||||
|
if (ev.isRunning) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
dragStartClientX = e.clientX;
|
||||||
|
dragStartClientY = e.clientY;
|
||||||
|
dragStartEventTop = dayEvent.top;
|
||||||
|
dragOriginalEvent = ev;
|
||||||
|
hasMoved = false;
|
||||||
|
dragOriginalHeight.value = dayEvent.height;
|
||||||
|
|
||||||
|
const s = params.calendarSettings.value;
|
||||||
|
dragVisibleDurationMinutes.value = (dayEvent.height / SLOT_HEIGHT) * s.slotMinutes;
|
||||||
|
|
||||||
|
const originDay = params.getDayFromClientX(e.clientX);
|
||||||
|
dragOriginalDayStr.value = originDay;
|
||||||
|
|
||||||
|
if (ev.timeEntry.end) {
|
||||||
|
const evStart = getLocalizedDayJs(ev.timeEntry.start);
|
||||||
|
const evEnd = getLocalizedDayJs(ev.timeEntry.end);
|
||||||
|
dragFullDurationMinutes = evEnd.diff(evStart, 'minute');
|
||||||
|
} else {
|
||||||
|
dragFullDurationMinutes = dragVisibleDurationMinutes.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dayEvent.isClippedStart && originDay && ev.timeEntry.end) {
|
||||||
|
const dayjs = getDayJsInstance();
|
||||||
|
const dayMidnight = dayjs(`${originDay}T00:00:00`).tz(getUserTimezone(), true);
|
||||||
|
const evStart = getLocalizedDayJs(ev.timeEntry.start);
|
||||||
|
const eventStartFromGridStart = evStart.diff(dayMidnight, 'minute') - s.startHour * 60;
|
||||||
|
const segmentTopMinutes = (dayEvent.top / SLOT_HEIGHT) * s.slotMinutes;
|
||||||
|
dragEventStartOffsetMinutes = segmentTopMinutes - eventStartFromGridStart;
|
||||||
|
} else {
|
||||||
|
dragEventStartOffsetMinutes = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gridY = params.clientYToGridPixels(e.clientY);
|
||||||
|
dragOffsetMinutes.value =
|
||||||
|
params.pixelsToMinutesFromMidnight(gridY) -
|
||||||
|
params.pixelsToMinutesFromMidnight(dayEvent.top);
|
||||||
|
|
||||||
|
document.addEventListener('pointermove', onDragPointerMove);
|
||||||
|
document.addEventListener('pointerup', onDragPointerUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragPointerMove(e: PointerEvent) {
|
||||||
|
const dx = e.clientX - dragStartClientX;
|
||||||
|
const dy = e.clientY - dragStartClientY;
|
||||||
|
|
||||||
|
if (!hasMoved && Math.sqrt(dx * dx + dy * dy) < DRAG_THRESHOLD) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasMoved) {
|
||||||
|
hasMoved = true;
|
||||||
|
isDragging.value = true;
|
||||||
|
dragEventId.value = dragOriginalEvent!.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gridY = params.clientYToGridPixels(e.clientY);
|
||||||
|
const s = params.calendarSettings.value;
|
||||||
|
const startMin = s.startHour * 60;
|
||||||
|
|
||||||
|
const rawMinutes = params.pixelsToMinutesFromMidnight(gridY) - dragOffsetMinutes.value;
|
||||||
|
const snappedMinutes = Math.floor(rawMinutes / s.snapMinutes) * s.snapMinutes;
|
||||||
|
const lowerBound = startMin - 4 * 60;
|
||||||
|
const clampedMinutes = Math.max(lowerBound, Math.min(snappedMinutes, s.endHour * 60));
|
||||||
|
dragCurrentTop.value = params.minutesToPixels(clampedMinutes - startMin);
|
||||||
|
|
||||||
|
const dayStr = params.getDayFromClientX(e.clientX);
|
||||||
|
if (dayStr) {
|
||||||
|
dragCurrentDay.value = dayStr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDragPointerUp(e: PointerEvent) {
|
||||||
|
document.removeEventListener('pointermove', onDragPointerMove);
|
||||||
|
document.removeEventListener('pointerup', onDragPointerUp);
|
||||||
|
|
||||||
|
if (!hasMoved) {
|
||||||
|
isDragging.value = false;
|
||||||
|
dragEventId.value = null;
|
||||||
|
dragOriginalDayStr.value = null;
|
||||||
|
dragCurrentDay.value = null;
|
||||||
|
if (dragOriginalEvent && !dragOriginalEvent.isRunning) {
|
||||||
|
params.onClickEvent(dragOriginalEvent);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetDateStr =
|
||||||
|
dragCurrentDay.value ||
|
||||||
|
dragOriginalDayStr.value ||
|
||||||
|
params.viewDays.value[0]!.format('YYYY-MM-DD');
|
||||||
|
const savedOriginalDayStr = dragOriginalDayStr.value || targetDateStr;
|
||||||
|
|
||||||
|
isDragging.value = false;
|
||||||
|
dragEventId.value = null;
|
||||||
|
dragOriginalDayStr.value = null;
|
||||||
|
dragCurrentDay.value = null;
|
||||||
|
|
||||||
|
if (!dragOriginalEvent) return;
|
||||||
|
const timeEntry = dragOriginalEvent.timeEntry;
|
||||||
|
if (!timeEntry.end) return;
|
||||||
|
|
||||||
|
const s = params.calendarSettings.value;
|
||||||
|
const gridY = params.clientYToGridPixels(e.clientY);
|
||||||
|
const rawMinutes = params.pixelsToMinutesFromMidnight(gridY) - dragOffsetMinutes.value;
|
||||||
|
const snappedMinutes = Math.floor(rawMinutes / s.snapMinutes) * s.snapMinutes;
|
||||||
|
const startMin = s.startHour * 60;
|
||||||
|
const lowerBound = startMin - 4 * 60;
|
||||||
|
const clampedMinutes = Math.max(lowerBound, Math.min(snappedMinutes, s.endHour * 60));
|
||||||
|
|
||||||
|
const dayjs = getDayJsInstance();
|
||||||
|
const originalSegmentStart = dayjs(`${savedOriginalDayStr}T00:00:00`)
|
||||||
|
.tz(getUserTimezone(), true)
|
||||||
|
.add(startMin + params.pixelsToMinutesFromMidnight(dragStartEventTop), 'minute');
|
||||||
|
const newSegmentStart = dayjs(`${targetDateStr}T00:00:00`)
|
||||||
|
.tz(getUserTimezone(), true)
|
||||||
|
.add(clampedMinutes, 'minute');
|
||||||
|
const deltaMs = newSegmentStart.diff(originalSegmentStart);
|
||||||
|
|
||||||
|
const origStart = getLocalizedDayJs(timeEntry.start);
|
||||||
|
const origEnd = getLocalizedDayJs(timeEntry.end);
|
||||||
|
const durationMs = origEnd.diff(origStart);
|
||||||
|
const newStartLocal = origStart.add(deltaMs, 'millisecond');
|
||||||
|
const newEndLocal = newStartLocal.add(durationMs, 'millisecond');
|
||||||
|
|
||||||
|
const updatedTimeEntry = {
|
||||||
|
...timeEntry,
|
||||||
|
start: newStartLocal.utc().format(),
|
||||||
|
end: newEndLocal.utc().format(),
|
||||||
|
} as TimeEntry;
|
||||||
|
|
||||||
|
params.optimisticOverrides.value = new Map(params.optimisticOverrides.value).set(
|
||||||
|
updatedTimeEntry.id,
|
||||||
|
updatedTimeEntry
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await params.updateTimeEntry(updatedTimeEntry);
|
||||||
|
} catch {
|
||||||
|
// Revert optimistic override on failure; mutation layer already shows error notification
|
||||||
|
const reverted = new Map(params.optimisticOverrides.value);
|
||||||
|
reverted.delete(updatedTimeEntry.id);
|
||||||
|
params.optimisticOverrides.value = reverted;
|
||||||
|
}
|
||||||
|
params.emitRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes a preview style for every day column that the dragged event
|
||||||
|
* would span. Derives the actual start/end datetime of the moved event,
|
||||||
|
* then clips each view day's grid to show the visible portion.
|
||||||
|
*/
|
||||||
|
const dragPreviewsByDay = computed<Record<string, Record<string, string>>>(() => {
|
||||||
|
if (!isDragging.value || !dragOriginalEvent) return {};
|
||||||
|
if (!dragCurrentDay.value) return {};
|
||||||
|
|
||||||
|
const s = params.calendarSettings.value;
|
||||||
|
const gridTotalMinutes = (s.endHour - s.startHour) * 60;
|
||||||
|
const startMin = s.startHour * 60;
|
||||||
|
const currentTopMinutes = (dragCurrentTop.value / SLOT_HEIGHT) * s.slotMinutes;
|
||||||
|
|
||||||
|
const offset =
|
||||||
|
dragCurrentDay.value === dragOriginalDayStr.value ? dragEventStartOffsetMinutes : 0;
|
||||||
|
|
||||||
|
// Minutes from grid-start on cursor day where the event visually begins
|
||||||
|
const eventStartOnGrid = currentTopMinutes - offset;
|
||||||
|
|
||||||
|
const baseStyle = {
|
||||||
|
position: 'absolute',
|
||||||
|
left: '0',
|
||||||
|
right: '0',
|
||||||
|
backgroundColor: dragOriginalEvent.backgroundColor,
|
||||||
|
borderColor: dragOriginalEvent.borderColor,
|
||||||
|
opacity: '0.7',
|
||||||
|
zIndex: '100',
|
||||||
|
borderRadius: 'calc(var(--radius) - 4px)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Single-day fast path: event fits within cursor day's grid
|
||||||
|
const eventEndOnGrid = eventStartOnGrid + dragFullDurationMinutes;
|
||||||
|
if (eventEndOnGrid <= gridTotalMinutes && eventStartOnGrid >= 0) {
|
||||||
|
const previewTop = params.minutesToPixels(eventStartOnGrid);
|
||||||
|
const previewHeight = params.minutesToPixels(
|
||||||
|
Math.max(s.snapMinutes, dragFullDurationMinutes)
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
[dragCurrentDay.value]: {
|
||||||
|
...baseStyle,
|
||||||
|
top: `${previewTop}px`,
|
||||||
|
height: `${previewHeight}px`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multi-day: compute actual start/end datetimes, then clip per day
|
||||||
|
const dayjs = getDayJsInstance();
|
||||||
|
const eventStartAbsolute = dayjs(`${dragCurrentDay.value}T00:00:00`)
|
||||||
|
.tz(getUserTimezone(), true)
|
||||||
|
.add(startMin + eventStartOnGrid, 'minute');
|
||||||
|
const eventEndAbsolute = eventStartAbsolute.add(dragFullDurationMinutes, 'minute');
|
||||||
|
|
||||||
|
const result: Record<string, Record<string, string>> = {};
|
||||||
|
|
||||||
|
for (const viewDay of params.viewDays.value) {
|
||||||
|
const dayStr = viewDay.format('YYYY-MM-DD');
|
||||||
|
const dayGridStart = viewDay.startOf('day').add(s.startHour, 'hour');
|
||||||
|
const dayGridEnd = viewDay.startOf('day').add(s.endHour, 'hour');
|
||||||
|
|
||||||
|
// Does the event overlap this day's grid window?
|
||||||
|
if (eventEndAbsolute.isAfter(dayGridStart) && eventStartAbsolute.isBefore(dayGridEnd)) {
|
||||||
|
const segStart = eventStartAbsolute.isAfter(dayGridStart)
|
||||||
|
? eventStartAbsolute
|
||||||
|
: dayGridStart;
|
||||||
|
const segEnd = eventEndAbsolute.isBefore(dayGridEnd)
|
||||||
|
? eventEndAbsolute
|
||||||
|
: dayGridEnd;
|
||||||
|
const segStartMin = segStart.diff(dayGridStart, 'minute');
|
||||||
|
const segEndMin = segEnd.diff(dayGridStart, 'minute');
|
||||||
|
const segHeight = Math.max(s.snapMinutes, segEndMin - segStartMin);
|
||||||
|
|
||||||
|
result[dayStr] = {
|
||||||
|
...baseStyle,
|
||||||
|
top: `${params.minutesToPixels(segStartMin)}px`,
|
||||||
|
height: `${params.minutesToPixels(segHeight)}px`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('pointermove', onDragPointerMove);
|
||||||
|
document.removeEventListener('pointerup', onDragPointerUp);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
isDragging,
|
||||||
|
dragEventId,
|
||||||
|
dragCurrentTop,
|
||||||
|
dragCurrentDay,
|
||||||
|
dragOriginalDayStr,
|
||||||
|
dragOriginalHeight,
|
||||||
|
dragVisibleDurationMinutes,
|
||||||
|
dragPreviewsByDay,
|
||||||
|
onEventPointerDown,
|
||||||
|
};
|
||||||
|
}
|
||||||
363
resources/js/packages/ui/src/FullCalendar/useEventResize.ts
Normal file
363
resources/js/packages/ui/src/FullCalendar/useEventResize.ts
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
import { computed, ref, onUnmounted, type Ref, type ComputedRef } from 'vue';
|
||||||
|
import type { Dayjs } from 'dayjs';
|
||||||
|
import type { TimeEntry } from '@/packages/api/src';
|
||||||
|
import { getDayJsInstance, getLocalizedDayJs } from '../utils/time';
|
||||||
|
import { getUserTimezone } from '../utils/settings';
|
||||||
|
import type { CalendarSettings } from './calendarSettings';
|
||||||
|
import type { CalendarEvent, DayEvent } from './calendarTypes';
|
||||||
|
import { SLOT_HEIGHT } from './calendarTypes';
|
||||||
|
|
||||||
|
function snapTo(value: number, step: number): number {
|
||||||
|
return Math.round(value / step) * step;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dayMidnightLocal(dayStr: string): Dayjs {
|
||||||
|
return getDayJsInstance()(`${dayStr}T00:00:00`).tz(getUserTimezone(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEventResize(params: {
|
||||||
|
calendarSettings: Ref<CalendarSettings>;
|
||||||
|
viewDays: ComputedRef<Dayjs[]>;
|
||||||
|
eventsByDay: ComputedRef<Record<string, DayEvent[]>>;
|
||||||
|
optimisticOverrides: Ref<Map<string, TimeEntry>>;
|
||||||
|
updateTimeEntry: (entry: TimeEntry) => Promise<void>;
|
||||||
|
emitRefresh: () => void;
|
||||||
|
minutesToPixels: (minutes: number) => number;
|
||||||
|
pixelsToMinutesFromMidnight: (px: number) => number;
|
||||||
|
getDayFromClientX: (clientX: number) => string | null;
|
||||||
|
clientYToGridPixels: (clientY: number) => number;
|
||||||
|
}) {
|
||||||
|
const isResizing = ref(false);
|
||||||
|
const resizeEventId = ref<string | null>(null);
|
||||||
|
const resizeEdge = ref<'start' | 'end'>('end');
|
||||||
|
const resizeCurrentTop = ref(0);
|
||||||
|
const resizeCurrentHeight = ref(0);
|
||||||
|
const resizeCurrentDay = ref<string | null>(null);
|
||||||
|
// Reactive so resizeLiveDurationSeconds recomputes during cross-day resize
|
||||||
|
const lastResizeClientY = ref(0);
|
||||||
|
|
||||||
|
let resizeOriginalEvent: CalendarEvent | null = null;
|
||||||
|
let resizeOriginalTop = 0;
|
||||||
|
let resizeOriginalHeight = 0;
|
||||||
|
let resizeOriginalDayStr = '';
|
||||||
|
|
||||||
|
function getGridConstants() {
|
||||||
|
const s = params.calendarSettings.value;
|
||||||
|
const startMin = s.startHour * 60;
|
||||||
|
const endMin = s.endHour * 60;
|
||||||
|
const snapPx = (s.snapMinutes / s.slotMinutes) * SLOT_HEIGHT;
|
||||||
|
const totalGridPx = params.minutesToPixels(endMin - startMin);
|
||||||
|
return { s, startMin, endMin, snapPx, totalGridPx };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the resolved start/end times for the current resize state.
|
||||||
|
* Used by both resizeLiveDurationSeconds and onResizePointerUp to avoid
|
||||||
|
* duplicating the 4-way (edge × cross-day) branching logic.
|
||||||
|
*
|
||||||
|
* For cross-day resizes, the cursor position (clientY) determines the
|
||||||
|
* snapped time on the target day. For same-day resizes, the already-snapped
|
||||||
|
* pixel state (resizeCurrentTop/Height) is used instead.
|
||||||
|
*/
|
||||||
|
function computeResizedTimes(clientY: number): { start: Dayjs; end: Dayjs } | null {
|
||||||
|
if (!resizeOriginalEvent) return null;
|
||||||
|
|
||||||
|
const { s } = getGridConstants();
|
||||||
|
const d = getDayJsInstance();
|
||||||
|
const isCrossDay =
|
||||||
|
resizeCurrentDay.value !== null && resizeCurrentDay.value !== resizeOriginalDayStr;
|
||||||
|
|
||||||
|
function snappedMinutesFromCursor(): number {
|
||||||
|
return snapTo(
|
||||||
|
params.pixelsToMinutesFromMidnight(params.clientYToGridPixels(clientY)),
|
||||||
|
s.snapMinutes
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resizeEdge.value === 'end') {
|
||||||
|
const start = d(resizeOriginalEvent.timeEntry.start);
|
||||||
|
const endDay =
|
||||||
|
isCrossDay && resizeCurrentDay.value
|
||||||
|
? resizeCurrentDay.value
|
||||||
|
: resizeOriginalDayStr;
|
||||||
|
const endMinutes =
|
||||||
|
isCrossDay && resizeCurrentDay.value
|
||||||
|
? snappedMinutesFromCursor()
|
||||||
|
: snapTo(
|
||||||
|
params.pixelsToMinutesFromMidnight(
|
||||||
|
resizeCurrentTop.value + resizeCurrentHeight.value
|
||||||
|
),
|
||||||
|
s.snapMinutes
|
||||||
|
);
|
||||||
|
return { start, end: dayMidnightLocal(endDay).add(endMinutes, 'minute') };
|
||||||
|
} else {
|
||||||
|
const end = resizeOriginalEvent.isRunning
|
||||||
|
? getLocalizedDayJs()
|
||||||
|
: d(resizeOriginalEvent.timeEntry.end!);
|
||||||
|
const startDay =
|
||||||
|
isCrossDay && resizeCurrentDay.value
|
||||||
|
? resizeCurrentDay.value
|
||||||
|
: resizeOriginalDayStr;
|
||||||
|
const startMinutes =
|
||||||
|
isCrossDay && resizeCurrentDay.value
|
||||||
|
? snappedMinutesFromCursor()
|
||||||
|
: snapTo(
|
||||||
|
params.pixelsToMinutesFromMidnight(resizeCurrentTop.value),
|
||||||
|
s.snapMinutes
|
||||||
|
);
|
||||||
|
return { start: dayMidnightLocal(startDay).add(startMinutes, 'minute'), end };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onResizerPointerDown(
|
||||||
|
e: PointerEvent,
|
||||||
|
ev: CalendarEvent,
|
||||||
|
dayEvent: DayEvent,
|
||||||
|
edge: 'start' | 'end',
|
||||||
|
dayStr: string
|
||||||
|
) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
resizeOriginalEvent = ev;
|
||||||
|
resizeEdge.value = edge;
|
||||||
|
resizeOriginalTop = dayEvent.top;
|
||||||
|
resizeOriginalHeight = dayEvent.height;
|
||||||
|
resizeEventId.value = ev.id;
|
||||||
|
isResizing.value = true;
|
||||||
|
resizeCurrentTop.value = dayEvent.top;
|
||||||
|
resizeCurrentHeight.value = dayEvent.height;
|
||||||
|
resizeOriginalDayStr = dayStr;
|
||||||
|
resizeCurrentDay.value = dayStr;
|
||||||
|
|
||||||
|
document.body.classList.add('fc-resizing-active');
|
||||||
|
document.addEventListener('pointermove', onResizePointerMove);
|
||||||
|
document.addEventListener('pointerup', onResizePointerUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onResizePointerMove(e: PointerEvent) {
|
||||||
|
if (!resizeOriginalEvent) return;
|
||||||
|
const { s, snapPx, totalGridPx } = getGridConstants();
|
||||||
|
|
||||||
|
const dayStr = params.getDayFromClientX(e.clientX);
|
||||||
|
if (dayStr) resizeCurrentDay.value = dayStr;
|
||||||
|
lastResizeClientY.value = e.clientY;
|
||||||
|
|
||||||
|
const isCrossDay = resizeCurrentDay.value !== resizeOriginalDayStr;
|
||||||
|
const cursorSnapped = snapTo(params.clientYToGridPixels(e.clientY), snapPx);
|
||||||
|
|
||||||
|
if (resizeEdge.value === 'end') {
|
||||||
|
if (isCrossDay && resizeCurrentDay.value) {
|
||||||
|
resizeCurrentHeight.value =
|
||||||
|
resizeCurrentDay.value > resizeOriginalDayStr
|
||||||
|
? totalGridPx - resizeOriginalTop
|
||||||
|
: snapPx;
|
||||||
|
} else {
|
||||||
|
const snappedEnd = Math.max(
|
||||||
|
resizeOriginalTop + snapPx,
|
||||||
|
Math.min(cursorSnapped, totalGridPx)
|
||||||
|
);
|
||||||
|
resizeCurrentHeight.value = snappedEnd - resizeOriginalTop;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let maxTopForRunning = Infinity;
|
||||||
|
if (resizeOriginalEvent.isRunning) {
|
||||||
|
const now = getLocalizedDayJs();
|
||||||
|
const nowMinutes = now.hour() * 60 + now.minute() + now.second() / 60;
|
||||||
|
maxTopForRunning = params.minutesToPixels(
|
||||||
|
Math.min(nowMinutes, s.endHour * 60) - s.startHour * 60
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCrossDay && resizeCurrentDay.value) {
|
||||||
|
if (resizeCurrentDay.value < resizeOriginalDayStr) {
|
||||||
|
resizeCurrentTop.value = 0;
|
||||||
|
resizeCurrentHeight.value = resizeOriginalTop + resizeOriginalHeight;
|
||||||
|
} else if (!resizeOriginalEvent.isRunning) {
|
||||||
|
const bottom = resizeOriginalTop + resizeOriginalHeight;
|
||||||
|
resizeCurrentTop.value = bottom - snapPx;
|
||||||
|
resizeCurrentHeight.value = snapPx;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const bottom = resizeOriginalTop + resizeOriginalHeight;
|
||||||
|
const upperLimit = Math.min(bottom - snapPx, maxTopForRunning);
|
||||||
|
const snappedStart = Math.max(0, Math.min(cursorSnapped, upperLimit));
|
||||||
|
resizeCurrentTop.value = snappedStart;
|
||||||
|
resizeCurrentHeight.value = bottom - snappedStart;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resizeCrossDayPreviewsByDay = computed<Record<string, Record<string, string>>>(() => {
|
||||||
|
if (!isResizing.value || !resizeOriginalEvent) return {};
|
||||||
|
if (resizeCurrentDay.value === resizeOriginalDayStr) return {};
|
||||||
|
|
||||||
|
const { startMin, snapPx, totalGridPx } = getGridConstants();
|
||||||
|
const snappedY = snapTo(params.clientYToGridPixels(lastResizeClientY.value), snapPx);
|
||||||
|
|
||||||
|
const baseStyle = {
|
||||||
|
position: 'absolute',
|
||||||
|
left: '0',
|
||||||
|
right: '0',
|
||||||
|
backgroundColor: resizeOriginalEvent.backgroundColor,
|
||||||
|
borderColor: resizeOriginalEvent.borderColor,
|
||||||
|
opacity: '0.5',
|
||||||
|
zIndex: '100',
|
||||||
|
borderRadius: 'calc(var(--radius) - 4px)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result: Record<string, Record<string, string>> = {};
|
||||||
|
const allDays = params.viewDays.value.map((d) => d.format('YYYY-MM-DD'));
|
||||||
|
const originIdx = allDays.indexOf(resizeOriginalDayStr);
|
||||||
|
const cursorIdx = allDays.indexOf(resizeCurrentDay.value!);
|
||||||
|
if (originIdx < 0 || cursorIdx < 0) return {};
|
||||||
|
|
||||||
|
const minIdx = Math.min(originIdx, cursorIdx);
|
||||||
|
const maxIdx = Math.max(originIdx, cursorIdx);
|
||||||
|
|
||||||
|
for (let i = minIdx; i <= maxIdx; i++) {
|
||||||
|
const dayStr = allDays[i]!;
|
||||||
|
if (dayStr === resizeOriginalDayStr) continue;
|
||||||
|
|
||||||
|
if (dayStr === resizeCurrentDay.value) {
|
||||||
|
if (resizeEdge.value === 'end') {
|
||||||
|
const eventStartDay = resizeOriginalEvent.dayStart.format('YYYY-MM-DD');
|
||||||
|
let previewTop = 0;
|
||||||
|
if (dayStr === eventStartDay) {
|
||||||
|
const eventStartMinutes =
|
||||||
|
resizeOriginalEvent.dayStart.hour() * 60 +
|
||||||
|
resizeOriginalEvent.dayStart.minute();
|
||||||
|
previewTop = params.minutesToPixels(
|
||||||
|
Math.max(0, eventStartMinutes - startMin)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const clampedY = Math.max(previewTop + snapPx, Math.min(snappedY, totalGridPx));
|
||||||
|
result[dayStr] = {
|
||||||
|
...baseStyle,
|
||||||
|
top: `${previewTop}px`,
|
||||||
|
height: `${clampedY - previewTop}px`,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const eventEndDay = resizeOriginalEvent.dayEnd.format('YYYY-MM-DD');
|
||||||
|
let previewBottom = totalGridPx;
|
||||||
|
if (dayStr === eventEndDay) {
|
||||||
|
const eventEndMinutes =
|
||||||
|
resizeOriginalEvent.dayEnd.hour() * 60 +
|
||||||
|
resizeOriginalEvent.dayEnd.minute();
|
||||||
|
previewBottom = params.minutesToPixels(
|
||||||
|
Math.max(0, eventEndMinutes - startMin)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const clampedY = Math.max(0, Math.min(snappedY, previewBottom - snapPx));
|
||||||
|
result[dayStr] = {
|
||||||
|
...baseStyle,
|
||||||
|
top: `${clampedY}px`,
|
||||||
|
height: `${previewBottom - clampedY}px`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result[dayStr] = { ...baseStyle, top: '0px', height: `${totalGridPx}px` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
const resizeLiveDurationSeconds = computed<number | null>(() => {
|
||||||
|
if (!isResizing.value || !resizeOriginalEvent) return null;
|
||||||
|
const times = computeResizedTimes(lastResizeClientY.value);
|
||||||
|
if (!times) return null;
|
||||||
|
const diff = times.end.diff(times.start, 'second');
|
||||||
|
return diff > 0 ? diff : 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
function getResizeOriginalDayStr(): string {
|
||||||
|
return resizeOriginalDayStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetResizeState() {
|
||||||
|
resizeEventId.value = null;
|
||||||
|
resizeCurrentDay.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onResizePointerUp(e: PointerEvent) {
|
||||||
|
document.removeEventListener('pointermove', onResizePointerMove);
|
||||||
|
document.removeEventListener('pointerup', onResizePointerUp);
|
||||||
|
document.body.classList.remove('fc-resizing-active');
|
||||||
|
|
||||||
|
const times = computeResizedTimes(e.clientY);
|
||||||
|
isResizing.value = false;
|
||||||
|
|
||||||
|
if (!resizeOriginalEvent || !times) {
|
||||||
|
resetResizeState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeEntry = resizeOriginalEvent.timeEntry;
|
||||||
|
const isRunning = resizeOriginalEvent.isRunning;
|
||||||
|
|
||||||
|
const updatedTimeEntry = {
|
||||||
|
...timeEntry,
|
||||||
|
start: resizeEdge.value === 'start' ? times.start.utc().format() : timeEntry.start,
|
||||||
|
end:
|
||||||
|
resizeEdge.value === 'end'
|
||||||
|
? times.end.utc().format()
|
||||||
|
: isRunning
|
||||||
|
? null
|
||||||
|
: timeEntry.end,
|
||||||
|
} as TimeEntry;
|
||||||
|
|
||||||
|
const d = getDayJsInstance();
|
||||||
|
|
||||||
|
// Prevent end before start
|
||||||
|
if (
|
||||||
|
updatedTimeEntry.end !== null &&
|
||||||
|
!d(updatedTimeEntry.end).isAfter(d(updatedTimeEntry.start))
|
||||||
|
) {
|
||||||
|
resetResizeState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent start in the future for running entries
|
||||||
|
if (updatedTimeEntry.end === null && d(updatedTimeEntry.start).isAfter(d())) {
|
||||||
|
resetResizeState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resetResizeState();
|
||||||
|
|
||||||
|
params.optimisticOverrides.value = new Map(params.optimisticOverrides.value).set(
|
||||||
|
updatedTimeEntry.id,
|
||||||
|
updatedTimeEntry
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await params.updateTimeEntry(updatedTimeEntry);
|
||||||
|
} catch {
|
||||||
|
const reverted = new Map(params.optimisticOverrides.value);
|
||||||
|
reverted.delete(updatedTimeEntry.id);
|
||||||
|
params.optimisticOverrides.value = reverted;
|
||||||
|
}
|
||||||
|
params.emitRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('pointermove', onResizePointerMove);
|
||||||
|
document.removeEventListener('pointerup', onResizePointerUp);
|
||||||
|
document.body.classList.remove('fc-resizing-active');
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
isResizing,
|
||||||
|
resizeEventId,
|
||||||
|
resizeEdge,
|
||||||
|
resizeCurrentTop,
|
||||||
|
resizeCurrentHeight,
|
||||||
|
resizeCurrentDay,
|
||||||
|
resizeCrossDayPreviewsByDay,
|
||||||
|
resizeLiveDurationSeconds,
|
||||||
|
getResizeOriginalDayStr,
|
||||||
|
onResizerPointerDown,
|
||||||
|
};
|
||||||
|
}
|
||||||
205
resources/js/packages/ui/src/FullCalendar/useSlotSelection.ts
Normal file
205
resources/js/packages/ui/src/FullCalendar/useSlotSelection.ts
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import { computed, ref, onUnmounted, type Ref, type ComputedRef } from 'vue';
|
||||||
|
import type { Dayjs } from 'dayjs';
|
||||||
|
import { getDayJsInstance } from '../utils/time';
|
||||||
|
import { getUserTimezone } from '../utils/settings';
|
||||||
|
import type { CalendarSettings } from './calendarSettings';
|
||||||
|
import { SLOT_HEIGHT } from './calendarTypes';
|
||||||
|
|
||||||
|
export function useSlotSelection(params: {
|
||||||
|
calendarSettings: Ref<CalendarSettings>;
|
||||||
|
viewDays: ComputedRef<Dayjs[]>;
|
||||||
|
totalGridHeight: ComputedRef<number>;
|
||||||
|
pixelsToMinutesFromMidnight: (px: number) => number;
|
||||||
|
getDayFromClientX: (clientX: number) => string | null;
|
||||||
|
clientYToGridPixels: (clientY: number) => number;
|
||||||
|
onSelectionComplete: (start: Dayjs, end: Dayjs) => void;
|
||||||
|
}) {
|
||||||
|
const isSelecting = ref(false);
|
||||||
|
const selectionDay = ref<string | null>(null);
|
||||||
|
const selectionTop = ref(0);
|
||||||
|
const selectionHeight = ref(0);
|
||||||
|
const selectionEndDay = ref<string | null>(null);
|
||||||
|
const selectionEndTop = ref(0);
|
||||||
|
const selectionEndHeight = ref(0);
|
||||||
|
|
||||||
|
// Non-reactive state
|
||||||
|
let selectionStartGridY = 0;
|
||||||
|
let selectionStartDay = '';
|
||||||
|
|
||||||
|
function onSlotPointerDown(e: PointerEvent) {
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.closest('.fc-event') || target.closest('.activity-status-box')) return;
|
||||||
|
|
||||||
|
const dateStr = params.getDayFromClientX(e.clientX);
|
||||||
|
if (!dateStr) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const gridY = params.clientYToGridPixels(e.clientY);
|
||||||
|
const s = params.calendarSettings.value;
|
||||||
|
const snapPx = (s.snapMinutes / s.slotMinutes) * SLOT_HEIGHT;
|
||||||
|
const snappedY = Math.floor(gridY / snapPx) * snapPx;
|
||||||
|
|
||||||
|
selectionStartGridY = snappedY;
|
||||||
|
selectionStartDay = dateStr;
|
||||||
|
selectionDay.value = dateStr;
|
||||||
|
selectionTop.value = snappedY;
|
||||||
|
selectionHeight.value = snapPx;
|
||||||
|
selectionEndDay.value = null;
|
||||||
|
selectionEndTop.value = 0;
|
||||||
|
selectionEndHeight.value = 0;
|
||||||
|
isSelecting.value = true;
|
||||||
|
|
||||||
|
document.addEventListener('pointermove', onSelectionPointerMove);
|
||||||
|
document.addEventListener('pointerup', onSelectionPointerUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSelectionPointerMove(e: PointerEvent) {
|
||||||
|
if (!isSelecting.value) return;
|
||||||
|
const gridY = params.clientYToGridPixels(e.clientY);
|
||||||
|
const s = params.calendarSettings.value;
|
||||||
|
const snapPx = (s.snapMinutes / s.slotMinutes) * SLOT_HEIGHT;
|
||||||
|
const maxPx = params.totalGridHeight.value;
|
||||||
|
|
||||||
|
const currentDay = params.getDayFromClientX(e.clientX);
|
||||||
|
|
||||||
|
if (currentDay && currentDay !== selectionStartDay) {
|
||||||
|
selectionTop.value = selectionStartGridY;
|
||||||
|
selectionHeight.value = maxPx - selectionStartGridY;
|
||||||
|
|
||||||
|
selectionEndDay.value = currentDay;
|
||||||
|
const snappedEnd = Math.ceil(gridY / snapPx) * snapPx;
|
||||||
|
selectionEndTop.value = 0;
|
||||||
|
selectionEndHeight.value = Math.max(snapPx, Math.min(snappedEnd, maxPx));
|
||||||
|
} else {
|
||||||
|
selectionEndDay.value = null;
|
||||||
|
|
||||||
|
if (gridY >= selectionStartGridY) {
|
||||||
|
const snappedEnd = Math.ceil(gridY / snapPx) * snapPx;
|
||||||
|
selectionTop.value = selectionStartGridY;
|
||||||
|
selectionHeight.value = Math.max(
|
||||||
|
snapPx,
|
||||||
|
Math.min(snappedEnd - selectionStartGridY, maxPx - selectionStartGridY)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const snappedStart = Math.floor(gridY / snapPx) * snapPx;
|
||||||
|
selectionTop.value = Math.max(0, snappedStart);
|
||||||
|
selectionHeight.value = Math.max(
|
||||||
|
snapPx,
|
||||||
|
selectionStartGridY + snapPx - selectionTop.value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSelectionPointerUp() {
|
||||||
|
document.removeEventListener('pointermove', onSelectionPointerMove);
|
||||||
|
document.removeEventListener('pointerup', onSelectionPointerUp);
|
||||||
|
|
||||||
|
if (!isSelecting.value) return;
|
||||||
|
isSelecting.value = false;
|
||||||
|
|
||||||
|
const s = params.calendarSettings.value;
|
||||||
|
const snap = s.snapMinutes;
|
||||||
|
const dayjs = getDayJsInstance();
|
||||||
|
|
||||||
|
const startMinutes = params.pixelsToMinutesFromMidnight(selectionTop.value);
|
||||||
|
const snappedStartMin = Math.floor(startMinutes / snap) * snap;
|
||||||
|
|
||||||
|
let startLocal;
|
||||||
|
let endLocal;
|
||||||
|
|
||||||
|
if (selectionEndDay.value && selectionEndDay.value !== selectionStartDay) {
|
||||||
|
const endMinutes = params.pixelsToMinutesFromMidnight(
|
||||||
|
selectionEndTop.value + selectionEndHeight.value
|
||||||
|
);
|
||||||
|
let snappedEndMin = Math.ceil(endMinutes / snap) * snap;
|
||||||
|
if (snappedEndMin <= 0) snappedEndMin = snap;
|
||||||
|
|
||||||
|
let startDateStr = selectionStartDay;
|
||||||
|
let endDateStr = selectionEndDay.value;
|
||||||
|
let startMin = snappedStartMin;
|
||||||
|
let endMin = snappedEndMin;
|
||||||
|
|
||||||
|
// Normalize: ensure start day is before end day (handle right-to-left selection)
|
||||||
|
if (endDateStr < startDateStr) {
|
||||||
|
[startDateStr, endDateStr] = [endDateStr, startDateStr];
|
||||||
|
// Cursor position on earlier day = bottom of end-day box
|
||||||
|
startMin =
|
||||||
|
Math.floor(
|
||||||
|
params.pixelsToMinutesFromMidnight(
|
||||||
|
selectionEndTop.value + selectionEndHeight.value
|
||||||
|
) / snap
|
||||||
|
) * snap;
|
||||||
|
// Click position on later day = top of start-day box
|
||||||
|
endMin =
|
||||||
|
Math.ceil(params.pixelsToMinutesFromMidnight(selectionTop.value) / snap) * snap;
|
||||||
|
if (endMin <= 0) endMin = snap;
|
||||||
|
}
|
||||||
|
|
||||||
|
startLocal = dayjs(`${startDateStr}T00:00:00`)
|
||||||
|
.tz(getUserTimezone(), true)
|
||||||
|
.add(startMin, 'minute');
|
||||||
|
endLocal = dayjs(`${endDateStr}T00:00:00`)
|
||||||
|
.tz(getUserTimezone(), true)
|
||||||
|
.add(endMin, 'minute');
|
||||||
|
} else {
|
||||||
|
const startDateStr = selectionStartDay;
|
||||||
|
const endMinutes = params.pixelsToMinutesFromMidnight(
|
||||||
|
selectionTop.value + selectionHeight.value
|
||||||
|
);
|
||||||
|
let snappedEndMin = Math.ceil(endMinutes / snap) * snap;
|
||||||
|
if (snappedEndMin <= snappedStartMin) {
|
||||||
|
snappedEndMin = snappedStartMin + snap;
|
||||||
|
}
|
||||||
|
startLocal = dayjs(`${startDateStr}T00:00:00`)
|
||||||
|
.tz(getUserTimezone(), true)
|
||||||
|
.add(snappedStartMin, 'minute');
|
||||||
|
endLocal = dayjs(`${startDateStr}T00:00:00`)
|
||||||
|
.tz(getUserTimezone(), true)
|
||||||
|
.add(snappedEndMin, 'minute');
|
||||||
|
}
|
||||||
|
|
||||||
|
params.onSelectionComplete(startLocal.utc(), endLocal.utc());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intermediate days between start and end day (excluding both) that need full-height selection
|
||||||
|
const selectionIntermediateDays = computed<Set<string>>(() => {
|
||||||
|
if (!selectionDay.value || !selectionEndDay.value) return new Set();
|
||||||
|
const allDays = params.viewDays.value.map((d) => d.format('YYYY-MM-DD'));
|
||||||
|
const startIdx = allDays.indexOf(selectionDay.value);
|
||||||
|
const endIdx = allDays.indexOf(selectionEndDay.value);
|
||||||
|
if (startIdx < 0 || endIdx < 0) return new Set();
|
||||||
|
const minIdx = Math.min(startIdx, endIdx);
|
||||||
|
const maxIdx = Math.max(startIdx, endIdx);
|
||||||
|
const days = new Set<string>();
|
||||||
|
for (let i = minIdx + 1; i < maxIdx; i++) {
|
||||||
|
days.add(allDays[i]!);
|
||||||
|
}
|
||||||
|
return days;
|
||||||
|
});
|
||||||
|
|
||||||
|
function clearSelection() {
|
||||||
|
selectionDay.value = null;
|
||||||
|
selectionEndDay.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('pointermove', onSelectionPointerMove);
|
||||||
|
document.removeEventListener('pointerup', onSelectionPointerUp);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
isSelecting,
|
||||||
|
selectionDay,
|
||||||
|
selectionTop,
|
||||||
|
selectionHeight,
|
||||||
|
selectionEndDay,
|
||||||
|
selectionEndTop,
|
||||||
|
selectionEndHeight,
|
||||||
|
selectionIntermediateDays,
|
||||||
|
onSlotPointerDown,
|
||||||
|
clearSelection,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
import { onActivated, onDeactivated, onMounted, onUnmounted, type Ref } from 'vue';
|
|
||||||
import type FullCalendar from '@fullcalendar/vue3';
|
|
||||||
|
|
||||||
interface VisualSnapOptions {
|
|
||||||
calendarRef: Ref<InstanceType<typeof FullCalendar> | null>;
|
|
||||||
snapMinutes: () => number;
|
|
||||||
slotMinutes: () => number;
|
|
||||||
formatDuration: (durationSeconds: number) => string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useVisualSnap({
|
|
||||||
calendarRef,
|
|
||||||
snapMinutes,
|
|
||||||
slotMinutes,
|
|
||||||
formatDuration,
|
|
||||||
}: VisualSnapOptions) {
|
|
||||||
let rafId: number | null = null;
|
|
||||||
|
|
||||||
function getCalendarEl(): HTMLElement | null {
|
|
||||||
return (calendarRef.value?.$el as HTMLElement) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSnapPixels(): number {
|
|
||||||
const calendarEl = getCalendarEl();
|
|
||||||
if (!calendarEl) return 25;
|
|
||||||
const slot = calendarEl.querySelector('.fc-timegrid-slot-lane') as HTMLElement;
|
|
||||||
if (!slot) return 25;
|
|
||||||
const slotHeightPx = slot.getBoundingClientRect().height;
|
|
||||||
return (snapMinutes() / slotMinutes()) * slotHeightPx;
|
|
||||||
}
|
|
||||||
|
|
||||||
function findMirrorHarness(calendarEl: HTMLElement) {
|
|
||||||
const mirror = calendarEl.querySelector('.fc-event-mirror') as HTMLElement | null;
|
|
||||||
const harness = mirror?.closest('.fc-timegrid-event-harness') as HTMLElement | null;
|
|
||||||
if (harness) {
|
|
||||||
harness.style.pointerEvents = 'none';
|
|
||||||
}
|
|
||||||
return { mirror, harness };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Copy the Vue-rendered event content from the original event into the
|
|
||||||
* FC mirror element (which only gets FC's default time-only rendering).
|
|
||||||
*/
|
|
||||||
function copyEventContentToMirror(calendarEl: HTMLElement, mirror: HTMLElement) {
|
|
||||||
const originalEvent = calendarEl.querySelector(
|
|
||||||
'.fc-event-resizing:not(.fc-event-mirror), .fc-event-dragging:not(.fc-event-mirror)'
|
|
||||||
) as HTMLElement | null;
|
|
||||||
if (!originalEvent) return;
|
|
||||||
const originalMain = originalEvent.querySelector('.fc-event-main') as HTMLElement | null;
|
|
||||||
const mirrorMain = mirror.querySelector('.fc-event-main') as HTMLElement | null;
|
|
||||||
if (!originalMain || !mirrorMain) return;
|
|
||||||
mirrorMain.innerHTML = originalMain.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateMirrorDurationLabel(
|
|
||||||
mirror: HTMLElement,
|
|
||||||
snappedTop: number,
|
|
||||||
snappedEnd: number,
|
|
||||||
snapPx: number
|
|
||||||
) {
|
|
||||||
const snappedDurationMin = Math.round((snappedEnd - snappedTop) / snapPx) * snapMinutes();
|
|
||||||
const durationText = formatDuration(snappedDurationMin * 60);
|
|
||||||
const durationEl = mirror.querySelector('[data-duration]');
|
|
||||||
if (durationEl) {
|
|
||||||
durationEl.textContent = durationText;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function startLoop(onFrame: (calendarEl: HTMLElement, snapPx: number) => void) {
|
|
||||||
const calendarEl = getCalendarEl();
|
|
||||||
if (!calendarEl) return;
|
|
||||||
const snapPx = getSnapPixels();
|
|
||||||
if (snapPx <= 0) return;
|
|
||||||
|
|
||||||
const loop = () => {
|
|
||||||
onFrame(calendarEl, snapPx);
|
|
||||||
rafId = requestAnimationFrame(loop);
|
|
||||||
};
|
|
||||||
rafId = requestAnimationFrame(loop);
|
|
||||||
}
|
|
||||||
|
|
||||||
function stop() {
|
|
||||||
document.body.classList.remove('fc-resizing-active');
|
|
||||||
if (rafId !== null) {
|
|
||||||
cancelAnimationFrame(rafId);
|
|
||||||
rafId = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Public snap starters ---
|
|
||||||
|
|
||||||
function startSelectSnap() {
|
|
||||||
// Don't start if another snap loop is already running
|
|
||||||
if (rafId !== null) return;
|
|
||||||
startLoop((calendarEl, snapPx) => {
|
|
||||||
const { mirror, harness } = findMirrorHarness(calendarEl);
|
|
||||||
if (!harness || !mirror) return;
|
|
||||||
|
|
||||||
const top = parseFloat(harness.style.top) || 0;
|
|
||||||
const endPos = -(parseFloat(harness.style.bottom) || 0);
|
|
||||||
const snappedTop = Math.floor(top / snapPx) * snapPx;
|
|
||||||
const snappedEnd = Math.ceil(endPos / snapPx) * snapPx;
|
|
||||||
const clampedEnd = Math.max(snappedTop + snapPx, snappedEnd);
|
|
||||||
harness.style.top = snappedTop + 'px';
|
|
||||||
harness.style.bottom = -clampedEnd + 'px';
|
|
||||||
updateMirrorDurationLabel(mirror, snappedTop, clampedEnd, snapPx);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function startDragSnap() {
|
|
||||||
stop();
|
|
||||||
startLoop((calendarEl, snapPx) => {
|
|
||||||
const { harness } = findMirrorHarness(calendarEl);
|
|
||||||
if (!harness) return;
|
|
||||||
|
|
||||||
const top = parseFloat(harness.style.top) || 0;
|
|
||||||
const endPos = -(parseFloat(harness.style.bottom) || 0);
|
|
||||||
const height = endPos - top;
|
|
||||||
const snappedTop = Math.floor(top / snapPx) * snapPx;
|
|
||||||
harness.style.top = snappedTop + 'px';
|
|
||||||
harness.style.bottom = -(snappedTop + height) + 'px';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function startResizeSnap() {
|
|
||||||
stop();
|
|
||||||
document.body.classList.add('fc-resizing-active');
|
|
||||||
|
|
||||||
let initialTop: number | null = null;
|
|
||||||
let initialEnd: number | null = null;
|
|
||||||
let resizeEdge: 'top' | 'bottom' | null = null;
|
|
||||||
let contentCopied = false;
|
|
||||||
|
|
||||||
startLoop((calendarEl, snapPx) => {
|
|
||||||
const { mirror, harness } = findMirrorHarness(calendarEl);
|
|
||||||
if (!harness) return;
|
|
||||||
|
|
||||||
if (mirror && !contentCopied) {
|
|
||||||
copyEventContentToMirror(calendarEl, mirror);
|
|
||||||
contentCopied = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const top = parseFloat(harness.style.top) || 0;
|
|
||||||
const endPos = -(parseFloat(harness.style.bottom) || 0);
|
|
||||||
|
|
||||||
// Detect which edge is being resized
|
|
||||||
if (initialTop === null) {
|
|
||||||
initialTop = top;
|
|
||||||
initialEnd = endPos;
|
|
||||||
} else if (resizeEdge === null) {
|
|
||||||
const topDelta = Math.abs(top - initialTop);
|
|
||||||
const endDelta = Math.abs(endPos - initialEnd!);
|
|
||||||
if (topDelta > 0.5) {
|
|
||||||
resizeEdge = 'top';
|
|
||||||
} else if (endDelta > 0.5) {
|
|
||||||
resizeEdge = 'bottom';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resizeEdge === 'bottom') {
|
|
||||||
const snappedEnd = Math.round(endPos / snapPx) * snapPx;
|
|
||||||
const clampedEnd = Math.max(top + snapPx, snappedEnd);
|
|
||||||
harness.style.bottom = -clampedEnd + 'px';
|
|
||||||
if (mirror) updateMirrorDurationLabel(mirror, top, clampedEnd, snapPx);
|
|
||||||
} else if (resizeEdge === 'top') {
|
|
||||||
const snappedTop = Math.round(top / snapPx) * snapPx;
|
|
||||||
const clampedTop = Math.min(endPos - snapPx, snappedTop);
|
|
||||||
harness.style.top = clampedTop + 'px';
|
|
||||||
if (mirror) updateMirrorDurationLabel(mirror, clampedTop, endPos, snapPx);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pointerdown handler for starting select snap on timegrid background
|
|
||||||
function handleTimegridPointerDown(e: PointerEvent) {
|
|
||||||
const target = e.target as HTMLElement;
|
|
||||||
if (target.closest('.fc-event')) return;
|
|
||||||
startSelectSnap();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lifecycle: attach/detach pointerdown listener
|
|
||||||
function attachListener() {
|
|
||||||
const calendarEl = getCalendarEl();
|
|
||||||
calendarEl?.addEventListener('pointerdown', handleTimegridPointerDown);
|
|
||||||
}
|
|
||||||
|
|
||||||
function detachListener() {
|
|
||||||
const calendarEl = getCalendarEl();
|
|
||||||
calendarEl?.removeEventListener('pointerdown', handleTimegridPointerDown);
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(attachListener);
|
|
||||||
onActivated(attachListener);
|
|
||||||
onDeactivated(() => {
|
|
||||||
stop();
|
|
||||||
detachListener();
|
|
||||||
});
|
|
||||||
onUnmounted(() => {
|
|
||||||
stop();
|
|
||||||
detachListener();
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
startSelectSnap,
|
|
||||||
startDragSnap,
|
|
||||||
startResizeSnap,
|
|
||||||
stop,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user