Replace FullCalendar with custom calendar UI

This commit is contained in:
Gregor Vostrak
2026-03-11 13:35:53 +01:00
parent 8d16503541
commit 189682cfaf
21 changed files with 5325 additions and 1514 deletions

View File

@@ -2,10 +2,11 @@ import type { Page } from '@playwright/test';
import { expect } from '@playwright/test'; import { expect } from '@playwright/test';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures'; import { test } from '../playwright/fixtures';
import { createBareTimeEntryViaApi, createTimeEntryWithTimestampsViaApi } from './utils/api';
async function goToCalendar(page: Page) { async function goToCalendar(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/calendar'); await page.goto(PLAYWRIGHT_BASE_URL + '/calendar');
await expect(page.locator('.fc')).toBeVisible(); await expect(page.locator('.fc')).toBeVisible({ timeout: 10000 });
} }
async function openSettingsPopover(page: Page) { async function openSettingsPopover(page: Page) {
@@ -21,6 +22,25 @@ function getCalendarTitle(page: Page) {
return page.getByTestId('calendar-title'); return page.getByTestId('calendar-title');
} }
async function scrollCalendarToTime(page: Page, time: string) {
await page.evaluate((t) => {
const slot = document.querySelector(`.fc-timegrid-slot-lane[data-time="${t}"]`);
if (slot) slot.scrollIntoView({ block: 'start' });
}, time);
await page.waitForTimeout(300);
}
async function getSlotHeight(page: Page): Promise<number> {
return await page.evaluate(() => {
const slots = Array.from(document.querySelectorAll('.fc-timegrid-slot-lane'));
for (let i = 0; i < slots.length; i++) {
const h = slots[i].getBoundingClientRect().height;
if (h > 0) return h;
}
return 20;
});
}
test.describe('Calendar Settings', () => { test.describe('Calendar Settings', () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await clearCalendarSettings(page); await clearCalendarSettings(page);
@@ -253,3 +273,402 @@ test.describe('Calendar Toolbar', () => {
await expect(page.locator('.fc-col-header-cell')).not.toHaveCount(1); await expect(page.locator('.fc-col-header-cell')).not.toHaveCount(1);
}); });
}); });
test.describe('Visual Snapping', () => {
test.beforeEach(async ({ page }) => {
await clearCalendarSettings(page);
});
test('snap interval of 1 minute allows fine-grained positioning', async ({ page, ctx }) => {
await goToCalendar(page);
await openSettingsPopover(page);
// Set snap interval to 1 min
await page.getByLabel('Snap Interval').click();
await page.getByRole('option', { name: '1 min' }).click();
await page.keyboard.press('Escape');
// Create a 1h time entry
await createBareTimeEntryViaApi(ctx, 'Snap 1min test', '1h');
await goToCalendar(page);
// Scroll the calendar so the 14:00 target area is visible
await scrollCalendarToTime(page, '13:00:00');
const event = page.locator('.fc-event').first();
await expect(event).toBeVisible();
// Get target slot at a non-15-min boundary time
const targetSlot = page.locator('.fc-timegrid-slot-lane[data-time="14:00:00"]').first();
const targetBox = await targetSlot.boundingBox();
expect(targetBox).not.toBeNull();
// Drag event to a position offset from the 15-min boundary
const putResponsePromise = page.waitForResponse(
(resp) => resp.url().includes('/time-entries/') && resp.request().method() === 'PUT'
);
await event.hover();
await page.mouse.down();
await page.mouse.move(targetBox!.x + targetBox!.width / 2, targetBox!.y + 5, { steps: 10 });
await page.mouse.up();
const putResponse = await putResponsePromise;
expect(putResponse.status()).toBe(200);
const body = await putResponse.json();
const startDate = new Date(body.data.start);
const minutes = startDate.getMinutes();
// With 1-min snap, any minute value is valid (0-59)
expect(minutes).toBeGreaterThanOrEqual(0);
expect(minutes).toBeLessThanOrEqual(59);
});
test('snap interval of 60 minutes creates hour-aligned entries', async ({ page, ctx }) => {
await goToCalendar(page);
await openSettingsPopover(page);
// Set snap interval to 60 min
await page.getByLabel('Snap Interval').click();
await page.getByRole('option', { name: '1 hour' }).click();
await page.keyboard.press('Escape');
// Create a 1h time entry
await createBareTimeEntryViaApi(ctx, 'Snap 60min test', '1h');
await goToCalendar(page);
// Scroll the calendar so the 14:00 target area is visible
await scrollCalendarToTime(page, '13:00:00');
const event = page.locator('.fc-event').first();
await expect(event).toBeVisible();
// Get target slot
const targetSlot = page.locator('.fc-timegrid-slot-lane[data-time="14:00:00"]').first();
const targetBox = await targetSlot.boundingBox();
expect(targetBox).not.toBeNull();
// Drag event
const putResponsePromise = page.waitForResponse(
(resp) => resp.url().includes('/time-entries/') && resp.request().method() === 'PUT'
);
await event.hover();
await page.mouse.down();
await page.mouse.move(targetBox!.x + targetBox!.width / 2, targetBox!.y + 5, { steps: 10 });
await page.mouse.up();
const putResponse = await putResponsePromise;
expect(putResponse.status()).toBe(200);
const body = await putResponse.json();
const startDate = new Date(body.data.start);
const minutes = startDate.getMinutes();
// With 60-min snap, minutes should be 0 (on the hour)
expect(minutes).toBe(0);
});
test('changing snap interval mid-session affects next drag', async ({ page, ctx }) => {
// Create a 1h time entry
await createBareTimeEntryViaApi(ctx, 'Snap change test', '1h');
await goToCalendar(page);
// Set snap to 15 min
await openSettingsPopover(page);
await page.getByLabel('Snap Interval').click();
await page.getByRole('option', { name: '15 min' }).click();
await page.keyboard.press('Escape');
// Scroll the calendar so the 14:00 target area is visible
await scrollCalendarToTime(page, '13:00:00');
const event = page.locator('.fc-event').first();
await expect(event).toBeVisible();
// Drag event to 14:00 area
const targetSlot14 = page.locator('.fc-timegrid-slot-lane[data-time="14:00:00"]').first();
const targetBox14 = await targetSlot14.boundingBox();
expect(targetBox14).not.toBeNull();
const putResponsePromise1 = page.waitForResponse(
(resp) => resp.url().includes('/time-entries/') && resp.request().method() === 'PUT'
);
await event.hover();
await page.mouse.down();
await page.mouse.move(targetBox14!.x + targetBox14!.width / 2, targetBox14!.y + 5, {
steps: 10,
});
await page.mouse.up();
const putResponse1 = await putResponsePromise1;
expect(putResponse1.status()).toBe(200);
const body1 = await putResponse1.json();
const startDate1 = new Date(body1.data.start);
expect(startDate1.getMinutes() % 15).toBe(0);
// Wait for query re-fetch/re-renders to fully settle after drag
await page.waitForTimeout(1500);
// Change snap to 30 min
// Use Escape first to ensure no stale popover is open, then re-open
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
await openSettingsPopover(page);
await page.waitForTimeout(300);
await page.getByLabel('Snap Interval').click({ force: true });
await page.getByRole('option', { name: '30 min' }).click();
await page.keyboard.press('Escape');
// Scroll the calendar so the 10:00 target area is visible
await scrollCalendarToTime(page, '09:00:00');
// Drag event to 10:00 area
const targetSlot10 = page.locator('.fc-timegrid-slot-lane[data-time="10:00:00"]').first();
const targetBox10 = await targetSlot10.boundingBox();
expect(targetBox10).not.toBeNull();
const putResponsePromise2 = page.waitForResponse(
(resp) => resp.url().includes('/time-entries/') && resp.request().method() === 'PUT'
);
await event.hover();
await page.mouse.down();
await page.mouse.move(targetBox10!.x + targetBox10!.width / 2, targetBox10!.y + 5, {
steps: 10,
});
await page.mouse.up();
const putResponse2 = await putResponsePromise2;
expect(putResponse2.status()).toBe(200);
const body2 = await putResponse2.json();
const startDate2 = new Date(body2.data.start);
expect(startDate2.getMinutes() % 30).toBe(0);
});
test('snap with different grid scale (slot != snap)', async ({ page, ctx }) => {
await goToCalendar(page);
await openSettingsPopover(page);
// Set grid scale to 30 min, snap to 5 min
await page.getByLabel('Grid Scale').click();
await page.getByRole('option', { name: '30 min' }).click();
await page.getByLabel('Snap Interval').click();
await page.getByRole('option', { name: '5 min', exact: true }).click();
await page.keyboard.press('Escape');
// Wait for re-render with 30-min grid
await expect(async () => {
const slotCount = await page.locator('.fc-timegrid-slot-lane').count();
// 24 hours * 2 slots/hour = 48 slots for 30-min grid
expect(slotCount).toBeLessThanOrEqual(48);
}).toPass({ timeout: 5000 });
// Verify grid is 30-min (fewer slots than default 15-min)
const slotCount = await page.locator('.fc-timegrid-slot-lane').count();
// Default 15-min grid has 96 slots; 30-min grid should have 48
expect(slotCount).toBeLessThanOrEqual(48);
// Create a 1h time entry and go to calendar
await createBareTimeEntryViaApi(ctx, 'Grid snap test', '1h');
await goToCalendar(page);
// Re-apply settings since goToCalendar navigates
await openSettingsPopover(page);
await page.getByLabel('Grid Scale').click();
await page.getByRole('option', { name: '30 min' }).click();
await page.getByLabel('Snap Interval').click();
await page.getByRole('option', { name: '5 min', exact: true }).click();
await page.keyboard.press('Escape');
const event = page.locator('.fc-event').first();
await expect(event).toBeVisible();
// Drag event
const targetSlot = page.locator('.fc-timegrid-slot-lane[data-time="14:00:00"]').first();
const targetBox = await targetSlot.boundingBox();
expect(targetBox).not.toBeNull();
const putResponsePromise = page.waitForResponse(
(resp) => resp.url().includes('/time-entries/') && resp.request().method() === 'PUT'
);
await event.hover();
await page.mouse.down();
await page.mouse.move(targetBox!.x + targetBox!.width / 2, targetBox!.y + 5, { steps: 10 });
await page.mouse.up();
const putResponse = await putResponsePromise;
expect(putResponse.status()).toBe(200);
const body = await putResponse.json();
const startDate = new Date(body.data.start);
// Snap is 5 min, so minutes should be divisible by 5
expect(startDate.getMinutes() % 5).toBe(0);
});
});
test.describe('Calendar Settings Effects', () => {
test.beforeEach(async ({ page }) => {
await clearCalendarSettings(page);
});
test('start/end time hides slots outside visible range', async ({ page, ctx }) => {
// Create a time entry at 6 AM today
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 6, 0, 0);
const end = new Date(start.getTime() + 3600 * 1000); // 7 AM
await createTimeEntryWithTimestampsViaApi(ctx, {
description: 'Early morning entry',
start: start.toISOString().replace(/\.\d{3}Z$/, 'Z'),
end: end.toISOString().replace(/\.\d{3}Z$/, 'Z'),
});
await goToCalendar(page);
// Verify 6 AM slot is visible with default settings
await expect(page.locator('.fc-timegrid-slot[data-time="06:00:00"]')).not.toHaveCount(0);
// Set start time to 8 AM
await openSettingsPopover(page);
await page.getByLabel('Start Time').click();
await page.getByRole('option', { name: '8:00 AM' }).click();
await page.keyboard.press('Escape');
// 6 AM slot should be hidden
await expect(page.locator('.fc-timegrid-slot[data-time="06:00:00"]')).toHaveCount(0);
// 8 AM slot should be visible
await expect(page.locator('.fc-timegrid-slot[data-time="08:00:00"]')).not.toHaveCount(0);
});
test('grid scale affects event visual height proportionally', async ({ page, ctx }) => {
// Create a 1h time entry
await createBareTimeEntryViaApi(ctx, 'Height test', '1h');
await goToCalendar(page);
const event = page.locator('.fc-event').first();
await expect(event).toBeVisible();
await event.scrollIntoViewIfNeeded();
// Get event height with default 15-min grid scale
const box15 = await event.boundingBox();
expect(box15).not.toBeNull();
const height15 = box15!.height;
// Change grid scale to 60 min
await openSettingsPopover(page);
await page.getByLabel('Grid Scale').click();
await page.getByRole('option', { name: '1 hour' }).click();
await page.keyboard.press('Escape');
// Wait for re-render and scroll event into view
await event.scrollIntoViewIfNeeded();
await expect(async () => {
const box = await event.boundingBox();
expect(box).not.toBeNull();
expect(box!.height).not.toBe(height15);
}).toPass({ timeout: 5000 });
const box60 = await event.boundingBox();
expect(box60).not.toBeNull();
const height60 = box60!.height;
// Event should appear smaller with larger grid scale
expect(height15).toBeGreaterThan(height60);
});
test('snap interval affects drag granularity', async ({ page, ctx }) => {
await goToCalendar(page);
await openSettingsPopover(page);
// Set snap to 30 min
await page.getByLabel('Snap Interval').click();
await page.getByRole('option', { name: '30 min' }).click();
await page.keyboard.press('Escape');
// Create a 1h time entry
await createBareTimeEntryViaApi(ctx, 'Drag granularity test', '1h');
await goToCalendar(page);
// Scroll the calendar so the 14:00 target area is visible
await scrollCalendarToTime(page, '13:00:00');
const event = page.locator('.fc-event').first();
await expect(event).toBeVisible();
// Get target slot
const targetSlot = page.locator('.fc-timegrid-slot-lane[data-time="14:00:00"]').first();
const targetBox = await targetSlot.boundingBox();
expect(targetBox).not.toBeNull();
// Drag event
const putResponsePromise = page.waitForResponse(
(resp) => resp.url().includes('/time-entries/') && resp.request().method() === 'PUT'
);
await event.hover();
await page.mouse.down();
await page.mouse.move(targetBox!.x + targetBox!.width / 2, targetBox!.y + 5, { steps: 10 });
await page.mouse.up();
const putResponse = await putResponsePromise;
expect(putResponse.status()).toBe(200);
const body = await putResponse.json();
const startDate = new Date(body.data.start);
const minutes = startDate.getMinutes();
// With 30-min snap, minutes should be 0 or 30
expect(minutes % 30).toBe(0);
});
test('settings apply immediately without page reload', async ({ page }) => {
await goToCalendar(page);
// Count slots with default grid scale (15 min)
const defaultSlotCount = await page.locator('.fc-timegrid-slot').count();
// Change grid scale to 30 min
await openSettingsPopover(page);
await page.getByLabel('Grid Scale').click();
await page.getByRole('option', { name: '30 min' }).click();
await page.keyboard.press('Escape');
// Verify slot count changed without navigation
await expect(async () => {
const count = await page.locator('.fc-timegrid-slot').count();
expect(count).toBeLessThan(defaultSlotCount);
}).toPass({ timeout: 5000 });
// Wait for FullCalendar to fully stabilize after re-render
await page.waitForTimeout(2000);
await expect(page.locator('.fc')).toBeVisible();
// Change start time to 8 AM
// FullCalendar re-render from grid scale change can make popover elements unstable.
// Retry the open+click sequence if it fails.
await expect(async () => {
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
await page.getByRole('button', { name: 'Calendar settings' }).click();
await expect(page.getByText('Calendar Settings')).toBeVisible();
const startTimeBtn = page.getByLabel('Start Time');
await expect(startTimeBtn).toBeVisible();
await startTimeBtn.click({ timeout: 3000 });
}).toPass({ timeout: 10000 });
await page.getByRole('option', { name: '8:00 AM' }).click();
await page.keyboard.press('Escape');
// Verify 7 AM slot is hidden without reload
await expect(async () => {
const count = await page.locator('.fc-timegrid-slot[data-time="07:00:00"]').count();
expect(count).toBe(0);
}).toPass({ timeout: 5000 });
});
});

File diff suppressed because it is too large Load Diff

134
package-lock.json generated
View File

@@ -11,11 +11,6 @@
"dependencies": { "dependencies": {
"@floating-ui/core": "^1.6.0", "@floating-ui/core": "^1.6.0",
"@floating-ui/vue": "^1.0.6", "@floating-ui/vue": "^1.0.6",
"@fullcalendar/core": "^6.1.18",
"@fullcalendar/daygrid": "^6.1.18",
"@fullcalendar/interaction": "^6.1.18",
"@fullcalendar/timegrid": "^6.1.18",
"@fullcalendar/vue3": "^6.1.18",
"@heroicons/vue": "^2.1.1", "@heroicons/vue": "^2.1.1",
"@rushstack/eslint-patch": "^1.10.5", "@rushstack/eslint-patch": "^1.10.5",
"@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/container-queries": "^0.1.1",
@@ -25,7 +20,7 @@
"@tanstack/vue-table": "^8.21.2", "@tanstack/vue-table": "^8.21.2",
"@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.3.0", "@vue/eslint-config-typescript": "^14.3.0",
"@vueuse/core": "^14.2.0", "@vueuse/core": "^14.2.1",
"@vueuse/integrations": "^14.0.0", "@vueuse/integrations": "^14.0.0",
"@zodios/core": "^10.9.6", "@zodios/core": "^10.9.6",
"chroma-js": "3.1.2", "chroma-js": "3.1.2",
@@ -38,7 +33,7 @@
"parse-duration": "^2.0.1", "parse-duration": "^2.0.1",
"pinia": "^3.0.0", "pinia": "^3.0.0",
"radix-vue": "^1.9.6", "radix-vue": "^1.9.6",
"reka-ui": "^2.8.0", "reka-ui": "^2.8.2",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"vue-echarts": "^8.0.0", "vue-echarts": "^8.0.0",
@@ -1037,55 +1032,6 @@
} }
} }
}, },
"node_modules/@fullcalendar/core": {
"version": "6.1.20",
"resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.20.tgz",
"integrity": "sha512-1cukXLlePFiJ8YKXn/4tMKsy0etxYLCkXk8nUCFi11nRONF2Ba2CD5b21/ovtOO2tL6afTJfwmc1ed3HG7eB1g==",
"license": "MIT",
"dependencies": {
"preact": "~10.12.1"
}
},
"node_modules/@fullcalendar/daygrid": {
"version": "6.1.20",
"resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.20.tgz",
"integrity": "sha512-AO9vqhkLP77EesmJzuU+IGXgxNulsA8mgQHynclJ8U70vSwAVnbcLG9qftiTAFSlZjiY/NvhE7sflve6cJelyQ==",
"license": "MIT",
"peerDependencies": {
"@fullcalendar/core": "~6.1.20"
}
},
"node_modules/@fullcalendar/interaction": {
"version": "6.1.20",
"resolved": "https://registry.npmjs.org/@fullcalendar/interaction/-/interaction-6.1.20.tgz",
"integrity": "sha512-p6txmc5txL0bMiPaJxe2ip6o0T384TyoD2KGdsU6UjZ5yoBlaY+dg7kxfnYKpYMzEJLG58n+URrHr2PgNL2fyA==",
"license": "MIT",
"peerDependencies": {
"@fullcalendar/core": "~6.1.20"
}
},
"node_modules/@fullcalendar/timegrid": {
"version": "6.1.20",
"resolved": "https://registry.npmjs.org/@fullcalendar/timegrid/-/timegrid-6.1.20.tgz",
"integrity": "sha512-4H+/MWbz3ntA50lrPif+7TsvMeX3R1GSYjiLULz0+zEJ7/Yfd9pupZmAwUs/PBpA6aAcFmeRr0laWfcz1a9V1A==",
"license": "MIT",
"dependencies": {
"@fullcalendar/daygrid": "~6.1.20"
},
"peerDependencies": {
"@fullcalendar/core": "~6.1.20"
}
},
"node_modules/@fullcalendar/vue3": {
"version": "6.1.20",
"resolved": "https://registry.npmjs.org/@fullcalendar/vue3/-/vue3-6.1.20.tgz",
"integrity": "sha512-8qg6pS27II9QBwFkkJC+7SfflMpWqOe7i3ii5ODq9KpLAjwQAd/zjfq8RvKR1Yryoh5UmMCmvRbMB7i4RGtqog==",
"license": "MIT",
"peerDependencies": {
"@fullcalendar/core": "~6.1.20",
"vue": "^3.0.11"
}
},
"node_modules/@heroicons/vue": { "node_modules/@heroicons/vue": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/@heroicons/vue/-/vue-2.2.0.tgz", "resolved": "https://registry.npmjs.org/@heroicons/vue/-/vue-2.2.0.tgz",
@@ -2996,14 +2942,14 @@
} }
}, },
"node_modules/@vueuse/core": { "node_modules/@vueuse/core": {
"version": "14.2.0", "version": "14.2.1",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.2.0.tgz", "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.2.1.tgz",
"integrity": "sha512-tpjzVl7KCQNVd/qcaCE9XbejL38V6KJAEq/tVXj7mDPtl6JtzmUdnXelSS+ULRkkrDgzYVK7EerQJvd2jR794Q==", "integrity": "sha512-3vwDzV+GDUNpdegRY6kzpLm4Igptq+GA0QkJ3W61Iv27YWwW/ufSlOfgQIpN6FZRMG0mkaz4gglJRtq5SeJyIQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/web-bluetooth": "^0.0.21", "@types/web-bluetooth": "^0.0.21",
"@vueuse/metadata": "14.2.0", "@vueuse/metadata": "14.2.1",
"@vueuse/shared": "14.2.0" "@vueuse/shared": "14.2.1"
}, },
"funding": { "funding": {
"url": "https://github.com/sponsors/antfu" "url": "https://github.com/sponsors/antfu"
@@ -3012,6 +2958,18 @@
"vue": "^3.5.0" "vue": "^3.5.0"
} }
}, },
"node_modules/@vueuse/core/node_modules/@vueuse/shared": {
"version": "14.2.1",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.2.1.tgz",
"integrity": "sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/@vueuse/integrations": { "node_modules/@vueuse/integrations": {
"version": "14.2.0", "version": "14.2.0",
"resolved": "https://registry.npmjs.org/@vueuse/integrations/-/integrations-14.2.0.tgz", "resolved": "https://registry.npmjs.org/@vueuse/integrations/-/integrations-14.2.0.tgz",
@@ -3078,7 +3036,24 @@
} }
} }
}, },
"node_modules/@vueuse/metadata": { "node_modules/@vueuse/integrations/node_modules/@vueuse/core": {
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.2.0.tgz",
"integrity": "sha512-tpjzVl7KCQNVd/qcaCE9XbejL38V6KJAEq/tVXj7mDPtl6JtzmUdnXelSS+ULRkkrDgzYVK7EerQJvd2jR794Q==",
"license": "MIT",
"dependencies": {
"@types/web-bluetooth": "^0.0.21",
"@vueuse/metadata": "14.2.0",
"@vueuse/shared": "14.2.0"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/@vueuse/integrations/node_modules/@vueuse/metadata": {
"version": "14.2.0", "version": "14.2.0",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.2.0.tgz", "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.2.0.tgz",
"integrity": "sha512-i3axTGjU8b13FtyR4Keeama+43iD+BwX9C2TmzBVKqjSHArF03hjkp2SBZ1m72Jk2UtrX0aYCugBq2R1fhkuAQ==", "integrity": "sha512-i3axTGjU8b13FtyR4Keeama+43iD+BwX9C2TmzBVKqjSHArF03hjkp2SBZ1m72Jk2UtrX0aYCugBq2R1fhkuAQ==",
@@ -3087,6 +3062,15 @@
"url": "https://github.com/sponsors/antfu" "url": "https://github.com/sponsors/antfu"
} }
}, },
"node_modules/@vueuse/metadata": {
"version": "14.2.1",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.2.1.tgz",
"integrity": "sha512-1ButlVtj5Sb/HDtIy1HFr1VqCP4G6Ypqt5MAo0lCgjokrk2mvQKsK2uuy0vqu/Ks+sHfuHo0B9Y9jn9xKdjZsw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/shared": { "node_modules/@vueuse/shared": {
"version": "14.2.0", "version": "14.2.0",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.2.0.tgz", "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.2.0.tgz",
@@ -5846,16 +5830,6 @@
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/preact": {
"version": "10.12.1",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz",
"integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/prelude-ls": { "node_modules/prelude-ls": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -6118,9 +6092,9 @@
} }
}, },
"node_modules/reka-ui": { "node_modules/reka-ui": {
"version": "2.8.0", "version": "2.8.2",
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.8.0.tgz", "resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.8.2.tgz",
"integrity": "sha512-N4JOyIrmDE7w2i06WytqcV2QICubtS2PsK5Uo8FIMAgmO13KhUAgAByP26cXjjm2oF/w7rTyRs8YaqtvaBT+SA==", "integrity": "sha512-8lTKcJhmG+D3UyJxhBnNnW/720sLzm0pbA9AC1MWazmJ5YchJAyTSl+O00xP/kxBmEN0fw5JqWVHguiFmsGjzA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@floating-ui/dom": "^1.6.13", "@floating-ui/dom": "^1.6.13",
@@ -6134,6 +6108,10 @@
"defu": "^6.1.4", "defu": "^6.1.4",
"ohash": "^2.0.11" "ohash": "^2.0.11"
}, },
"funding": {
"type": "github",
"url": "https://github.com/sponsors/zernonia"
},
"peerDependencies": { "peerDependencies": {
"vue": ">= 3.2.0" "vue": ">= 3.2.0"
} }
@@ -7442,12 +7420,18 @@
"peerDependencies": { "peerDependencies": {
"@floating-ui/vue": "^1.1.4", "@floating-ui/vue": "^1.1.4",
"@heroicons/vue": "^2.1.5", "@heroicons/vue": "^2.1.5",
"@internationalized/date": "^3.0.0",
"@vitejs/plugin-vue": "^5.1.2 || ^6.0.0", "@vitejs/plugin-vue": "^5.1.2 || ^6.0.0",
"@vueuse/core": "^12.5.0 || ^14.0.0", "@vueuse/core": "^12.5.0 || ^14.0.0",
"@vueuse/integrations": "^12.5.0 || ^14.0.0",
"chroma-js": "^3.1.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"focus-trap": "^7.0.0 || ^8.0.0",
"lucide-vue-next": ">=0.453.0",
"parse-duration": "^2.0.1", "parse-duration": "^2.0.1",
"radix-vue": "^1.9.0",
"reka-ui": "^2.2.0", "reka-ui": "^2.2.0",
"tailwind-merge": "^2.5.2", "tailwind-merge": "^2.5.2",
"tailwindcss": "^3.1.0", "tailwindcss": "^3.1.0",

View File

@@ -45,11 +45,6 @@
"dependencies": { "dependencies": {
"@floating-ui/core": "^1.6.0", "@floating-ui/core": "^1.6.0",
"@floating-ui/vue": "^1.0.6", "@floating-ui/vue": "^1.0.6",
"@fullcalendar/core": "^6.1.18",
"@fullcalendar/daygrid": "^6.1.18",
"@fullcalendar/interaction": "^6.1.18",
"@fullcalendar/timegrid": "^6.1.18",
"@fullcalendar/vue3": "^6.1.18",
"@heroicons/vue": "^2.1.1", "@heroicons/vue": "^2.1.1",
"@rushstack/eslint-patch": "^1.10.5", "@rushstack/eslint-patch": "^1.10.5",
"@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/container-queries": "^0.1.1",
@@ -59,7 +54,7 @@
"@tanstack/vue-table": "^8.21.2", "@tanstack/vue-table": "^8.21.2",
"@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.3.0", "@vue/eslint-config-typescript": "^14.3.0",
"@vueuse/core": "^14.2.0", "@vueuse/core": "^14.2.1",
"@vueuse/integrations": "^14.0.0", "@vueuse/integrations": "^14.0.0",
"@zodios/core": "^10.9.6", "@zodios/core": "^10.9.6",
"chroma-js": "3.1.2", "chroma-js": "3.1.2",
@@ -72,7 +67,7 @@
"parse-duration": "^2.0.1", "parse-duration": "^2.0.1",
"pinia": "^3.0.0", "pinia": "^3.0.0",
"radix-vue": "^1.9.6", "radix-vue": "^1.9.6",
"reka-ui": "^2.8.0", "reka-ui": "^2.8.2",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"vue-echarts": "^8.0.0", "vue-echarts": "^8.0.0",

View File

@@ -2,7 +2,7 @@
import AppLayout from '@/Layouts/AppLayout.vue'; import AppLayout from '@/Layouts/AppLayout.vue';
import { useTimeEntriesCalendarQuery } from '@/utils/useTimeEntriesCalendarQuery'; import { useTimeEntriesCalendarQuery } from '@/utils/useTimeEntriesCalendarQuery';
import { useTimeEntriesMutations } from '@/utils/useTimeEntriesMutations'; import { useTimeEntriesMutations } from '@/utils/useTimeEntriesMutations';
import { computed, ref } from 'vue'; import { computed, ref, onMounted } from 'vue';
import { useQueryClient } from '@tanstack/vue-query'; import { useQueryClient } from '@tanstack/vue-query';
import { import {
type Client, type Client,
@@ -11,6 +11,7 @@ import {
type Project, type Project,
} from '@/packages/api/src'; } from '@/packages/api/src';
import { TimeEntryCalendar } from '@/packages/ui/src'; import { TimeEntryCalendar } from '@/packages/ui/src';
import type { ActivityPeriod } from '@/packages/ui/src/FullCalendar/activityTypes';
import { isAllowedToPerformPremiumAction } from '@/utils/billing'; import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { useTagsStore } from '@/utils/useTags'; import { useTagsStore } from '@/utils/useTags';
import { useProjectsQuery } from '@/utils/useProjectsQuery'; import { useProjectsQuery } from '@/utils/useProjectsQuery';
@@ -26,6 +27,26 @@ import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
const calendarStart = ref<Date | undefined>(undefined); const calendarStart = ref<Date | undefined>(undefined);
const calendarEnd = ref<Date | undefined>(undefined); const calendarEnd = ref<Date | undefined>(undefined);
// Test-injectable activity periods (for E2E testing).
// These hooks are no-ops in production — they only take effect when test code
// explicitly sets window globals, so they are safe to ship.
const testActivityPeriods = ref<ActivityPeriod[]>([]);
onMounted(() => {
(window as Record<string, unknown>).__TEST_SET_ACTIVITY_PERIODS__ = (
data: ActivityPeriod[]
) => {
testActivityPeriods.value = data;
};
const windowData = (window as Record<string, unknown>).__TEST_ACTIVITY_PERIODS__;
if (Array.isArray(windowData)) {
setTimeout(() => {
testActivityPeriods.value = windowData;
}, 2000);
}
});
const { data: timeEntryResponse, isLoading: timeEntriesLoading } = useTimeEntriesCalendarQuery( const { data: timeEntryResponse, isLoading: timeEntriesLoading } = useTimeEntriesCalendarQuery(
calendarStart, calendarStart,
calendarEnd calendarEnd
@@ -89,7 +110,10 @@ function onRefresh() {
</script> </script>
<template> <template>
<AppLayout title="Calendar" data-testid="calendar_view" main-class="p-0"> <AppLayout
title="Calendar"
data-testid="calendar_view"
main-class="p-0 min-h-0 overflow-hidden">
<TimeEntryCalendar <TimeEntryCalendar
:time-entries="currentTimeEntries" :time-entries="currentTimeEntries"
:projects="projects" :projects="projects"
@@ -106,6 +130,7 @@ function onRefresh() {
:create-client="createClient" :create-client="createClient"
:create-project="createProject" :create-project="createProject"
:create-tag="createTag" :create-tag="createTag"
:activity-periods="testActivityPeriods"
@dates-change="onDatesChange" @dates-change="onDatesChange"
@refresh="onRefresh" /> @refresh="onRefresh" />
</AppLayout> </AppLayout>

View 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>

View File

@@ -1,30 +1,28 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, inject, type ComputedRef } from 'vue'; import { computed, inject, type ComputedRef } from 'vue';
import { formatDate, formatHumanReadableDuration } from '../utils/time'; import { formatHumanReadableDuration } from '../utils/time';
import type { Organization } from '@/packages/api/src'; import type { Organization } from '@/packages/api/src';
import type { Dayjs } from 'dayjs'; import type { Dayjs } from 'dayjs';
const props = defineProps<{ const props = defineProps<{
date: Dayjs; date: Dayjs;
totalSeconds?: number; totalSeconds?: number;
isToday?: boolean;
}>(); }>();
const totalSecondsValue = computed(() => props.totalSeconds ?? 0); const totalSecondsValue = computed(() => props.totalSeconds ?? 0);
// Injected organization for formatting settings
const organization = inject('organization') as ComputedRef<Organization | undefined> | undefined; const organization = inject('organization') as ComputedRef<Organization | undefined> | undefined;
const intervalFormat = computed(() => organization?.value?.interval_format); const intervalFormat = computed(() => organization?.value?.interval_format);
const numberFormat = computed(() => organization?.value?.number_format); const numberFormat = computed(() => organization?.value?.number_format);
const dateFormat = computed(() => organization?.value?.date_format);
</script> </script>
<template> <template>
<div class="fc-day-header-custom"> <div class="fc-day-header-custom">
<div class="text-xs text-muted-foreground font-medium"> <div class="text-sm text-foreground" :class="isToday ? 'font-semibold' : 'font-medium'">
{{ date.format('ddd') }} {{ date.format('ddd') }} {{ date.date() }}
</div> </div>
<span class="text-xs">{{ formatDate(date.toISOString(), dateFormat) }}</span> <span class="block text-xs text-muted-foreground font-medium mt-0.5">
<span class="block text-xs text-muted-foreground font-medium mt-1">
{{ formatHumanReadableDuration(totalSecondsValue, intervalFormat, numberFormat) }} {{ formatHumanReadableDuration(totalSecondsValue, intervalFormat, numberFormat) }}
</span> </span>
</div> </div>

View File

@@ -40,7 +40,7 @@ const formattedDuration = computed(() =>
</script> </script>
<template> <template>
<div class="text-2xs leading-tight px-0.5 py-1.5"> <div class="text-2xs leading-tight px-0.5 py-1">
<div class="font-semibold">{{ title }}</div> <div class="font-semibold">{{ title }}</div>
<div v-if="projectName" class="font-medium opacity-90"> <div v-if="projectName" class="font-medium opacity-90">
{{ projectName }} {{ projectName }}

File diff suppressed because it is too large Load Diff

View 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[];
}

View 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;
}

View File

@@ -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;

View 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,
};
}

View 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,
};
}

View 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,
};
}

View File

@@ -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,
};
}

View 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,
};
}

View 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,
};
}

View 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,
};
}

View 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,
};
}

View File

@@ -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,
};
}