mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 08:12:17 +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 { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||
import { test } from '../playwright/fixtures';
|
||||
import { createBareTimeEntryViaApi, createTimeEntryWithTimestampsViaApi } from './utils/api';
|
||||
|
||||
async function goToCalendar(page: Page) {
|
||||
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) {
|
||||
@@ -21,6 +22,25 @@ function getCalendarTitle(page: Page) {
|
||||
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.beforeEach(async ({ page }) => {
|
||||
await clearCalendarSettings(page);
|
||||
@@ -253,3 +273,402 @@ test.describe('Calendar Toolbar', () => {
|
||||
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": {
|
||||
"@floating-ui/core": "^1.6.0",
|
||||
"@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",
|
||||
"@rushstack/eslint-patch": "^1.10.5",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
@@ -25,7 +20,7 @@
|
||||
"@tanstack/vue-table": "^8.21.2",
|
||||
"@vue/eslint-config-prettier": "^10.2.0",
|
||||
"@vue/eslint-config-typescript": "^14.3.0",
|
||||
"@vueuse/core": "^14.2.0",
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"@vueuse/integrations": "^14.0.0",
|
||||
"@zodios/core": "^10.9.6",
|
||||
"chroma-js": "3.1.2",
|
||||
@@ -38,7 +33,7 @@
|
||||
"parse-duration": "^2.0.1",
|
||||
"pinia": "^3.0.0",
|
||||
"radix-vue": "^1.9.6",
|
||||
"reka-ui": "^2.8.0",
|
||||
"reka-ui": "^2.8.2",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"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": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@heroicons/vue/-/vue-2.2.0.tgz",
|
||||
@@ -2996,14 +2942,14 @@
|
||||
}
|
||||
},
|
||||
"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==",
|
||||
"version": "14.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.2.1.tgz",
|
||||
"integrity": "sha512-3vwDzV+GDUNpdegRY6kzpLm4Igptq+GA0QkJ3W61Iv27YWwW/ufSlOfgQIpN6FZRMG0mkaz4gglJRtq5SeJyIQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/web-bluetooth": "^0.0.21",
|
||||
"@vueuse/metadata": "14.2.0",
|
||||
"@vueuse/shared": "14.2.0"
|
||||
"@vueuse/metadata": "14.2.1",
|
||||
"@vueuse/shared": "14.2.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
@@ -3012,6 +2958,18 @@
|
||||
"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": {
|
||||
"version": "14.2.0",
|
||||
"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",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.2.0.tgz",
|
||||
"integrity": "sha512-i3axTGjU8b13FtyR4Keeama+43iD+BwX9C2TmzBVKqjSHArF03hjkp2SBZ1m72Jk2UtrX0aYCugBq2R1fhkuAQ==",
|
||||
@@ -3087,6 +3062,15 @@
|
||||
"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": {
|
||||
"version": "14.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.2.0.tgz",
|
||||
@@ -5846,16 +5830,6 @@
|
||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||
"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": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
@@ -6118,9 +6092,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/reka-ui": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.8.0.tgz",
|
||||
"integrity": "sha512-N4JOyIrmDE7w2i06WytqcV2QICubtS2PsK5Uo8FIMAgmO13KhUAgAByP26cXjjm2oF/w7rTyRs8YaqtvaBT+SA==",
|
||||
"version": "2.8.2",
|
||||
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.8.2.tgz",
|
||||
"integrity": "sha512-8lTKcJhmG+D3UyJxhBnNnW/720sLzm0pbA9AC1MWazmJ5YchJAyTSl+O00xP/kxBmEN0fw5JqWVHguiFmsGjzA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
@@ -6134,6 +6108,10 @@
|
||||
"defu": "^6.1.4",
|
||||
"ohash": "^2.0.11"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/zernonia"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": ">= 3.2.0"
|
||||
}
|
||||
@@ -7442,12 +7420,18 @@
|
||||
"peerDependencies": {
|
||||
"@floating-ui/vue": "^1.1.4",
|
||||
"@heroicons/vue": "^2.1.5",
|
||||
"@internationalized/date": "^3.0.0",
|
||||
"@vitejs/plugin-vue": "^5.1.2 || ^6.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",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"focus-trap": "^7.0.0 || ^8.0.0",
|
||||
"lucide-vue-next": ">=0.453.0",
|
||||
"parse-duration": "^2.0.1",
|
||||
"radix-vue": "^1.9.0",
|
||||
"reka-ui": "^2.2.0",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"tailwindcss": "^3.1.0",
|
||||
|
||||
@@ -45,11 +45,6 @@
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.6.0",
|
||||
"@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",
|
||||
"@rushstack/eslint-patch": "^1.10.5",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
@@ -59,7 +54,7 @@
|
||||
"@tanstack/vue-table": "^8.21.2",
|
||||
"@vue/eslint-config-prettier": "^10.2.0",
|
||||
"@vue/eslint-config-typescript": "^14.3.0",
|
||||
"@vueuse/core": "^14.2.0",
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"@vueuse/integrations": "^14.0.0",
|
||||
"@zodios/core": "^10.9.6",
|
||||
"chroma-js": "3.1.2",
|
||||
@@ -72,7 +67,7 @@
|
||||
"parse-duration": "^2.0.1",
|
||||
"pinia": "^3.0.0",
|
||||
"radix-vue": "^1.9.6",
|
||||
"reka-ui": "^2.8.0",
|
||||
"reka-ui": "^2.8.2",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"vue-echarts": "^8.0.0",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
import { useTimeEntriesCalendarQuery } from '@/utils/useTimeEntriesCalendarQuery';
|
||||
import { useTimeEntriesMutations } from '@/utils/useTimeEntriesMutations';
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, ref, onMounted } from 'vue';
|
||||
import { useQueryClient } from '@tanstack/vue-query';
|
||||
import {
|
||||
type Client,
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type Project,
|
||||
} from '@/packages/api/src';
|
||||
import { TimeEntryCalendar } from '@/packages/ui/src';
|
||||
import type { ActivityPeriod } from '@/packages/ui/src/FullCalendar/activityTypes';
|
||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||
import { useTagsStore } from '@/utils/useTags';
|
||||
import { useProjectsQuery } from '@/utils/useProjectsQuery';
|
||||
@@ -26,6 +27,26 @@ import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
|
||||
const calendarStart = 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(
|
||||
calendarStart,
|
||||
calendarEnd
|
||||
@@ -89,7 +110,10 @@ function onRefresh() {
|
||||
</script>
|
||||
|
||||
<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
|
||||
:time-entries="currentTimeEntries"
|
||||
:projects="projects"
|
||||
@@ -106,6 +130,7 @@ function onRefresh() {
|
||||
:create-client="createClient"
|
||||
:create-project="createProject"
|
||||
:create-tag="createTag"
|
||||
:activity-periods="testActivityPeriods"
|
||||
@dates-change="onDatesChange"
|
||||
@refresh="onRefresh" />
|
||||
</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">
|
||||
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 { Dayjs } from 'dayjs';
|
||||
|
||||
const props = defineProps<{
|
||||
date: Dayjs;
|
||||
totalSeconds?: number;
|
||||
isToday?: boolean;
|
||||
}>();
|
||||
|
||||
const totalSecondsValue = computed(() => props.totalSeconds ?? 0);
|
||||
|
||||
// Injected organization for formatting settings
|
||||
const organization = inject('organization') as ComputedRef<Organization | undefined> | undefined;
|
||||
const intervalFormat = computed(() => organization?.value?.interval_format);
|
||||
const numberFormat = computed(() => organization?.value?.number_format);
|
||||
const dateFormat = computed(() => organization?.value?.date_format);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fc-day-header-custom">
|
||||
<div class="text-xs text-muted-foreground font-medium">
|
||||
{{ date.format('ddd') }}
|
||||
<div class="text-sm text-foreground" :class="isToday ? 'font-semibold' : 'font-medium'">
|
||||
{{ date.format('ddd') }} {{ date.date() }}
|
||||
</div>
|
||||
<span class="text-xs">{{ formatDate(date.toISOString(), dateFormat) }}</span>
|
||||
<span class="block text-xs text-muted-foreground font-medium mt-1">
|
||||
<span class="block text-xs text-muted-foreground font-medium mt-0.5">
|
||||
{{ formatHumanReadableDuration(totalSecondsValue, intervalFormat, numberFormat) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -40,7 +40,7 @@ const formattedDuration = computed(() =>
|
||||
</script>
|
||||
|
||||
<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 v-if="projectName" class="font-medium opacity-90">
|
||||
{{ 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