Compare commits

..

10 Commits

Author SHA1 Message Date
Gregor Vostrak
785c8b939f only use xsrf token for organization requests 2026-03-02 17:08:08 +01:00
Gregor Vostrak
b2fa07b38b bump retries and wait for networkidle in retry 2026-03-02 16:57:15 +01:00
Gregor Vostrak
5b053bc2c1 add retries to api data token setup and xsrf token fallback 2026-03-02 16:44:51 +01:00
Gregor Vostrak
b775aaf1df use api tokens to create e2e test data 2026-03-02 15:47:02 +01:00
Gregor Vostrak
84c4750c9b Add warning for AI slop pull requests
Added a warning about AI slop pull requests and potential bans.
2026-02-27 20:18:44 +01:00
Gregor Vostrak
f582adab0d fix time entries incorrectly not updating in calendar
the synced snapDuration cause incorrect noops on updates f.e. 15:55-16:00 on a 15 minute snap
2026-02-24 19:38:55 +01:00
Gregor Vostrak
c60cff04ce fix calendar flickering on move for non-aligned entries
this is a trade-off where for non grid aligned entries, the cursor position is a bit off, but data and visual are stil in sync. otherwise fc overrides height on drag, causing flickers.
2026-02-24 15:30:18 +01:00
Gregor Vostrak
cae41e4b4f improve visual snapping boundaries 2026-02-24 14:02:18 +01:00
Gregor Vostrak
8973be9dab filament minor version update 2026-02-24 13:43:21 +01:00
Gregor Vostrak
2a0b8d31e6 add calendar settings + custom visual snapping 2026-02-24 12:41:15 +01:00
11 changed files with 1754 additions and 751 deletions

View File

@@ -37,6 +37,8 @@ If you have a **feature request**, please [**create a discussion**](https://gith
Please open an issue or start a discussion and wait for approval before submitting a pull request. This does not apply to tiny fixes or changes however, please keep in mind that we might not merge PRs for various reasons. Please open an issue or start a discussion and wait for approval before submitting a pull request. This does not apply to tiny fixes or changes however, please keep in mind that we might not merge PRs for various reasons.
**If you submit an AI slop pull request (especially without following the proper procedure), you will be banned from future contributions to solidtime.**
Please read the [CONTRIBUTING.md](./CONTRIBUTING.md) before sumbitting a Pull Request. Please read the [CONTRIBUTING.md](./CONTRIBUTING.md) before sumbitting a Pull Request.
We do accept contributions in the [documentation repository](https://github.com/solidtime-io/docs) f.e. to add new self-hosting guides. We do accept contributions in the [documentation repository](https://github.com/solidtime-io/docs) f.e. to add new self-hosting guides.

1562
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,172 @@
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures';
import { expect } from '@playwright/test';
import type { Page } from '@playwright/test';
async function goToCalendar(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/calendar');
await expect(page.locator('.fc')).toBeVisible();
}
async function openSettingsPopover(page: Page) {
await page.getByRole('button', { name: 'Calendar settings' }).click();
await expect(page.getByText('Calendar Settings')).toBeVisible();
}
async function clearCalendarSettings(page: Page) {
await page.evaluate(() => localStorage.removeItem('solidtime:calendar-settings'));
}
test.describe('Calendar Settings', () => {
test.beforeEach(async ({ page }) => {
await clearCalendarSettings(page);
});
test('settings popover shows all fields with correct defaults', async ({ page }) => {
await goToCalendar(page);
await openSettingsPopover(page);
await expect(page.getByLabel('Snap Interval')).toContainText('15 min');
await expect(page.getByLabel('Start Time')).toContainText('12:00 AM');
await expect(page.getByLabel('End Time')).toContainText('12:00 AM (next)');
await expect(page.getByLabel('Grid Scale')).toContainText('15 min');
});
test('snap interval can be changed and persists across reload', async ({ page }) => {
await goToCalendar(page);
await openSettingsPopover(page);
// Change snap interval to 30 min
await page.getByLabel('Snap Interval').click();
await page.getByRole('option', { name: '30 min' }).click();
await page.locator('.fc-toolbar-title').click();
// Verify localStorage was updated
const stored = await page.evaluate(() =>
JSON.parse(localStorage.getItem('solidtime:calendar-settings') || '{}')
);
expect(stored.snapMinutes).toBe(30);
// Reload and verify persistence
await page.reload();
await expect(page.locator('.fc')).toBeVisible();
await openSettingsPopover(page);
await expect(page.getByLabel('Snap Interval')).toContainText('30 min');
});
test('start time change is applied to calendar and rejects values >= end time', async ({
page,
}) => {
await goToCalendar(page);
// Verify 7 AM slot exists with default start (00:00)
await expect(page.locator('.fc-timegrid-slot[data-time="07:00:00"]')).not.toHaveCount(0);
await openSettingsPopover(page);
// Set end time to 6 PM first
await page.getByLabel('End Time').click();
await page.getByRole('option', { name: '6:00 PM' }).click();
// Change start time to 8 AM (valid)
await page.getByLabel('Start Time').click();
await page.getByRole('option', { name: '8:00 AM' }).click();
await page.locator('.fc-toolbar-title').click();
// Calendar should no longer show hours before 8 AM
await expect(page.locator('.fc-timegrid-slot[data-time="07:00:00"]')).toHaveCount(0);
await expect(page.locator('.fc-timegrid-slot[data-time="08:00:00"]')).not.toHaveCount(0);
// Try to set start time to 6 PM (invalid: equals end time)
await openSettingsPopover(page);
await page.getByLabel('Start Time').click();
await page.getByRole('option', { name: '6:00 PM' }).click();
// Should be rejected — start time stays at 8 AM
await expect(page.getByLabel('Start Time')).toContainText('8:00 AM');
});
test('end time change is applied to calendar and rejects values <= start time', async ({
page,
}) => {
await goToCalendar(page);
// Verify 19:00 slot exists with default end (24:00)
await expect(page.locator('.fc-timegrid-slot[data-time="19:00:00"]')).not.toHaveCount(0);
await openSettingsPopover(page);
// Set start time to 8 AM first
await page.getByLabel('Start Time').click();
await page.getByRole('option', { name: '8:00 AM' }).click();
// Change end time to 6 PM (valid)
await page.getByLabel('End Time').click();
await page.getByRole('option', { name: '6:00 PM' }).click();
await page.locator('.fc-toolbar-title').click();
// Calendar should no longer show hours at or after 6 PM
await expect(page.locator('.fc-timegrid-slot[data-time="18:00:00"]')).toHaveCount(0);
await expect(page.locator('.fc-timegrid-slot[data-time="17:00:00"]')).not.toHaveCount(0);
// Try to set end time to 8 AM (invalid: equals start time)
await openSettingsPopover(page);
await page.getByLabel('End Time').click();
await page.getByRole('option', { name: '8:00 AM' }).click();
// Should be rejected — end time stays at 6 PM
await expect(page.getByLabel('End Time')).toContainText('6:00 PM');
});
test('grid scale affects number of calendar slots', async ({ page }) => {
await goToCalendar(page);
// Count slots with default 15-min scale
const defaultSlotCount = await page.locator('.fc-timegrid-slot').count();
// Change to 30 min scale (should halve the slots)
await openSettingsPopover(page);
await page.getByLabel('Grid Scale').click();
await page.getByRole('option', { name: '30 min' }).click();
await page.locator('.fc-toolbar-title').click();
const largerSlotCount = await page.locator('.fc-timegrid-slot').count();
expect(largerSlotCount).toBeLessThan(defaultSlotCount);
// Change to 5 min scale (should have many more slots)
await openSettingsPopover(page);
await page.getByLabel('Grid Scale').click();
await page.getByRole('option', { name: '5 min', exact: true }).click();
await page.locator('.fc-toolbar-title').click();
const smallerSlotCount = await page.locator('.fc-timegrid-slot').count();
expect(smallerSlotCount).toBeGreaterThan(defaultSlotCount);
});
test('all settings persist across navigation', async ({ page }) => {
await goToCalendar(page);
await openSettingsPopover(page);
// Change every setting
await page.getByLabel('Snap Interval').click();
await page.getByRole('option', { name: '5 min', exact: true }).click();
await page.getByLabel('Start Time').click();
await page.getByRole('option', { name: '6:00 AM' }).click();
await page.getByLabel('End Time').click();
await page.getByRole('option', { name: '10:00 PM' }).click();
await page.getByLabel('Grid Scale').click();
await page.getByRole('option', { name: '30 min' }).click();
await page.locator('.fc-toolbar-title').click();
// Navigate away and back
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
await goToCalendar(page);
// Verify all settings persisted
await openSettingsPopover(page);
await expect(page.getByLabel('Snap Interval')).toContainText('5 min');
await expect(page.getByLabel('Start Time')).toContainText('6:00 AM');
await expect(page.getByLabel('End Time')).toContainText('10:00 PM');
await expect(page.getByLabel('Grid Scale')).toContainText('30 min');
});
});

View File

@@ -608,7 +608,7 @@ test('test that billable icon shows dollar sign for USD currency on time entry r
page, page,
ctx, ctx,
}) => { }) => {
await updateOrganizationCurrencyViaWeb(ctx, 'USD'); await updateOrganizationCurrencyViaWeb(page, ctx, 'USD');
await goToTimeOverview(page); await goToTimeOverview(page);
await createEmptyTimeEntry(page); await createEmptyTimeEntry(page);
const timeEntryRow = page.locator('[data-testid="time_entry_row"]').first(); const timeEntryRow = page.locator('[data-testid="time_entry_row"]').first();
@@ -621,7 +621,7 @@ test('test that billable icon shows euro sign for EUR currency on time entry row
page, page,
ctx, ctx,
}) => { }) => {
await updateOrganizationCurrencyViaWeb(ctx, 'EUR'); await updateOrganizationCurrencyViaWeb(page, ctx, 'EUR');
await goToTimeOverview(page); await goToTimeOverview(page);
await createEmptyTimeEntry(page); await createEmptyTimeEntry(page);
const timeEntryRow = page.locator('[data-testid="time_entry_row"]').first(); const timeEntryRow = page.locator('[data-testid="time_entry_row"]').first();

View File

@@ -30,7 +30,7 @@ test('test that starting and stopping a timer without description and project wo
}); });
test('test that billable icon shows dollar sign for USD currency', async ({ page, ctx }) => { test('test that billable icon shows dollar sign for USD currency', async ({ page, ctx }) => {
await updateOrganizationCurrencyViaWeb(ctx, 'USD'); await updateOrganizationCurrencyViaWeb(page, ctx, 'USD');
await goToDashboard(page); await goToDashboard(page);
await page.waitForLoadState('networkidle'); await page.waitForLoadState('networkidle');
const billableButton = page.getByRole('button', { name: 'Non Billable' }).first(); const billableButton = page.getByRole('button', { name: 'Non Billable' }).first();
@@ -39,7 +39,7 @@ test('test that billable icon shows dollar sign for USD currency', async ({ page
}); });
test('test that billable icon shows euro sign for EUR currency', async ({ page, ctx }) => { test('test that billable icon shows euro sign for EUR currency', async ({ page, ctx }) => {
await updateOrganizationCurrencyViaWeb(ctx, 'EUR'); await updateOrganizationCurrencyViaWeb(page, ctx, 'EUR');
await goToDashboard(page); await goToDashboard(page);
await page.waitForLoadState('networkidle'); await page.waitForLoadState('networkidle');
const billableButton = page.getByRole('button', { name: 'Non Billable' }).first(); const billableButton = page.getByRole('button', { name: 'Non Billable' }).first();

View File

@@ -16,12 +16,59 @@ export interface TestContext {
// Auth helpers // Auth helpers
// ────────────────────────────────────────────────── // ──────────────────────────────────────────────────
async function getApiHeaders(page: Page): Promise<Record<string, string>> { /**
const cookies = await page.context().cookies(); * Create a Passport API token by calling the token endpoint from the browser.
const xsrfCookie = cookies.find((c) => c.name === 'XSRF-TOKEN'); *
* The browser's native fetch includes the laravel_token cookie (set by
* CreateFreshApiToken during the dashboard page load), so authentication
* is handled by the browser's own cookie jar. The returned Bearer token is
* then used for all subsequent API calls, making them independent of cookie state.
*
* If the first attempt returns 401 (Octane hasn't fully committed the session yet),
* we reload the page to trigger a fresh CreateFreshApiToken and retry.
*/
async function createApiToken(page: Page): Promise<string> {
for (let attempt = 0; attempt < 3; attempt++) {
const result = await page.evaluate(async (baseUrl) => {
const xsrfCookie = document.cookie.split('; ').find((c) => c.startsWith('XSRF-TOKEN='));
const xsrfToken = xsrfCookie
? decodeURIComponent(xsrfCookie.split('=').slice(1).join('='))
: '';
const res = await fetch(`${baseUrl}/api/v1/users/me/api-tokens`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'X-XSRF-TOKEN': xsrfToken,
},
body: JSON.stringify({ name: 'playwright-test' }),
});
if (!res.ok) {
return null;
}
const body = await res.json();
return body.data.access_token as string;
}, PLAYWRIGHT_BASE_URL);
if (result) {
return result;
}
// Reload to get a fresh laravel_token cookie and retry.
// networkidle gives Octane time to fully commit the session.
await page.reload({ waitUntil: 'networkidle' });
}
throw new Error('Failed to create API token after retries');
}
function bearerHeaders(token: string): Record<string, string> {
return { return {
Accept: 'application/json', Accept: 'application/json',
...(xsrfCookie ? { 'X-XSRF-TOKEN': decodeURIComponent(xsrfCookie.value) } : {}), Authorization: `Bearer ${token}`,
}; };
} }
@@ -30,8 +77,10 @@ async function getApiHeaders(page: Page): Promise<Record<string, string>> {
// ────────────────────────────────────────────────── // ──────────────────────────────────────────────────
export async function setupTestContext(page: Page): Promise<TestContext> { export async function setupTestContext(page: Page): Promise<TestContext> {
const token = await createApiToken(page);
const request = page.request; const request = page.request;
const headers = await getApiHeaders(page); const headers = bearerHeaders(token);
const orgId = await getOrganizationId(request, headers); const orgId = await getOrganizationId(request, headers);
const memberId = await getCurrentMemberId(request, orgId, headers); const memberId = await getCurrentMemberId(request, orgId, headers);
return { request: createAuthenticatedRequest(request, headers), orgId, memberId }; return { request: createAuthenticatedRequest(request, headers), orgId, memberId };
@@ -491,11 +540,17 @@ export async function updateOrganizationSettingViaApi(
} }
export async function updateOrganizationCurrencyViaWeb( export async function updateOrganizationCurrencyViaWeb(
page: Page,
ctx: TestContext, ctx: TestContext,
currency: string, currency: string,
name: string = 'Test Organization' name: string = 'Test Organization'
) { ) {
const response = await ctx.request.put(`${PLAYWRIGHT_BASE_URL}/teams/${ctx.orgId}`, { const cookies = await page.context().cookies();
const xsrfCookie = cookies.find((c) => c.name === 'XSRF-TOKEN');
const xsrfToken = xsrfCookie ? decodeURIComponent(xsrfCookie.value) : '';
const response = await page.request.put(`${PLAYWRIGHT_BASE_URL}/teams/${ctx.orgId}`, {
headers: { 'X-XSRF-TOKEN': xsrfToken },
data: { name, currency }, data: { name, currency },
}); });
expect(response.status()).toBe(200); expect(response.status()).toBe(200);

View File

@@ -0,0 +1,198 @@
<script setup lang="ts">
import { Popover, PopoverContent, PopoverTrigger, Button } from '..';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/Components/ui/select';
import { Field, FieldLabel } from '../field';
import { Settings } from 'lucide-vue-next';
import { ref, watch } from 'vue';
import type { CalendarSettings } from './calendarSettings';
export type { CalendarSettings };
const props = defineProps<{
settings: CalendarSettings;
}>();
const emit = defineEmits<{
'update:settings': [value: CalendarSettings];
}>();
const snapMinutes = ref(String(props.settings.snapMinutes));
const startHour = ref(String(props.settings.startHour));
const endHour = ref(String(props.settings.endHour));
const slotMinutes = ref(String(props.settings.slotMinutes));
watch(
() => props.settings,
(s) => {
snapMinutes.value = String(s.snapMinutes);
startHour.value = String(s.startHour);
endHour.value = String(s.endHour);
slotMinutes.value = String(s.slotMinutes);
}
);
function emitUpdate(partial: Partial<CalendarSettings>) {
emit('update:settings', { ...props.settings, ...partial });
}
function onSnapChange(value: string) {
snapMinutes.value = value;
emitUpdate({ snapMinutes: parseInt(value) });
}
function onStartHourChange(value: string) {
const newStart = parseInt(value);
// Ensure start < end
if (newStart >= parseInt(endHour.value)) {
startHour.value = String(props.settings.startHour);
return;
}
startHour.value = value;
emitUpdate({ startHour: newStart });
}
function onEndHourChange(value: string) {
const newEnd = parseInt(value);
// Ensure end > start
if (newEnd <= parseInt(startHour.value)) {
endHour.value = String(props.settings.endHour);
return;
}
endHour.value = value;
emitUpdate({ endHour: newEnd });
}
function onSlotChange(value: string) {
slotMinutes.value = value;
emitUpdate({ slotMinutes: parseInt(value) });
}
const snapOptions = [
{ value: '1', label: '1 min' },
{ value: '5', label: '5 min' },
{ value: '10', label: '10 min' },
{ value: '15', label: '15 min' },
{ value: '30', label: '30 min' },
{ value: '60', label: '1 hour' },
];
const slotOptions = [
{ value: '5', label: '5 min' },
{ value: '10', label: '10 min' },
{ value: '15', label: '15 min' },
{ value: '30', label: '30 min' },
{ value: '60', label: '1 hour' },
];
// Generate hour options 0-24
const hourOptions = Array.from({ length: 25 }, (_, i) => ({
value: String(i),
label:
i === 0
? '12:00 AM'
: i === 12
? '12:00 PM'
: i === 24
? '12:00 AM (next)'
: i < 12
? `${i}:00 AM`
: `${i - 12}:00 PM`,
}));
</script>
<template>
<Popover>
<PopoverTrigger as-child>
<Button variant="outline" size="sm" aria-label="Calendar settings" class="h-8 w-8 p-0">
<Settings class="h-4 w-4 text-muted-foreground" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" class="w-72 p-4">
<div class="space-y-4">
<div class="text-sm font-semibold">Calendar Settings</div>
<Field>
<FieldLabel for="calendar-snap">Snap Interval</FieldLabel>
<Select
:model-value="snapMinutes"
@update:model-value="(v) => onSnapChange(v as string)">
<SelectTrigger id="calendar-snap" size="sm" class="w-full">
<SelectValue placeholder="Snap interval" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="opt in snapOptions"
:key="opt.value"
:value="opt.value">
{{ opt.label }}
</SelectItem>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel for="calendar-start-hour">Start Time</FieldLabel>
<Select
:model-value="startHour"
@update:model-value="(v) => onStartHourChange(v as string)">
<SelectTrigger id="calendar-start-hour" size="sm" class="w-full">
<SelectValue placeholder="Start time" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="opt in hourOptions.slice(0, -1)"
:key="opt.value"
:value="opt.value">
{{ opt.label }}
</SelectItem>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel for="calendar-end-hour">End Time</FieldLabel>
<Select
:model-value="endHour"
@update:model-value="(v) => onEndHourChange(v as string)">
<SelectTrigger id="calendar-end-hour" size="sm" class="w-full">
<SelectValue placeholder="End time" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="opt in hourOptions.slice(1)"
:key="opt.value"
:value="opt.value">
{{ opt.label }}
</SelectItem>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel for="calendar-scale">Grid Scale</FieldLabel>
<Select
:model-value="slotMinutes"
@update:model-value="(v) => onSlotChange(v as string)">
<SelectTrigger id="calendar-scale" size="sm" class="w-full">
<SelectValue placeholder="Grid scale" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="opt in slotOptions"
:key="opt.value"
:value="opt.value">
{{ opt.label }}
</SelectItem>
</SelectContent>
</Select>
</Field>
</div>
</PopoverContent>
</Popover>
</template>

View File

@@ -15,13 +15,22 @@ import {
onActivated, onActivated,
onUnmounted, onUnmounted,
} from 'vue'; } from 'vue';
import { useLocalStorage } from '@vueuse/core';
import chroma from 'chroma-js'; import chroma from 'chroma-js';
import { useCssVariable } from '@/utils/useCssVariable'; import { useCssVariable } from '@/utils/useCssVariable';
import { getDayJsInstance, getLocalizedDayJs } from '../utils/time'; import {
getDayJsInstance,
getLocalizedDayJs,
formatHumanReadableDuration,
formatDuration,
} from '../utils/time';
import { getUserTimezone, getWeekStart } from '../utils/settings'; import { getUserTimezone, getWeekStart } from '../utils/settings';
import { LoadingSpinner, TimeEntryCreateModal, TimeEntryEditModal } from '..'; import { LoadingSpinner, TimeEntryCreateModal, TimeEntryEditModal } from '..';
import FullCalendarEventContent from './FullCalendarEventContent.vue'; import FullCalendarEventContent from './FullCalendarEventContent.vue';
import FullCalendarDayHeader from './FullCalendarDayHeader.vue'; import FullCalendarDayHeader from './FullCalendarDayHeader.vue';
import CalendarSettingsPopover from './CalendarSettingsPopover.vue';
import type { CalendarSettings } from './calendarSettings';
import { useVisualSnap } from './useVisualSnap';
import activityStatusPlugin, { import activityStatusPlugin, {
type ActivityPeriod, type ActivityPeriod,
renderActivityStatusBoxes, renderActivityStatusBoxes,
@@ -81,6 +90,22 @@ const selectedTimeEntry = ref<TimeEntry | null>(null);
const calendarRef = ref<InstanceType<typeof FullCalendar> | null>(null); const calendarRef = ref<InstanceType<typeof FullCalendar> | null>(null);
// Calendar settings with localStorage persistence via VueUse
const calendarSettings = useLocalStorage<CalendarSettings>(
'solidtime:calendar-settings',
{
snapMinutes: 15,
startHour: 0,
endHour: 24,
slotMinutes: 15,
},
{ mergeDefaults: true }
);
function onSettingsUpdate(newSettings: CalendarSettings) {
calendarSettings.value = newSettings;
}
// Reactive "now" for running time entry - updates every minute // Reactive "now" for running time entry - updates every minute
const currentTime = ref(getDayJsInstance()()); const currentTime = ref(getDayJsInstance()());
let currentTimeInterval: ReturnType<typeof setInterval> | null = null; let currentTimeInterval: ReturnType<typeof setInterval> | null = null;
@@ -204,16 +229,19 @@ function emitDatesChange(arg: DatesSetArg) {
} }
function handleDateSelect(arg: { start: Date; end: Date }) { function handleDateSelect(arg: { start: Date; end: Date }) {
const startTime = getDayJsInstance()(arg.start.toISOString()) stopVisualSnap();
const snap = calendarSettings.value.snapMinutes;
const startLocal = getDayJsInstance()(arg.start.toISOString())
.utc() .utc()
.tz(getUserTimezone(), true) .tz(getUserTimezone(), true);
.utc(); const endLocal = getDayJsInstance()(arg.end.toISOString()).utc().tz(getUserTimezone(), true);
const endTime = getDayJsInstance()(arg.end.toISOString()) const snappedStart = snapStartToGrid(startLocal, snap);
.utc() let snappedEnd = snapEndToGrid(endLocal, snap);
.tz(getUserTimezone(), true) if (!snappedEnd.isAfter(snappedStart)) {
.utc(); snappedEnd = snappedStart.add(snap, 'minute');
newEventStart.value = startTime; }
newEventEnd.value = endTime; newEventStart.value = snappedStart.utc();
newEventEnd.value = snappedEnd.utc();
showCreateTimeEntryModal.value = true; showCreateTimeEntryModal.value = true;
} }
@@ -227,56 +255,106 @@ function handleEventClick(arg: EventClickArg) {
showEditTimeEntryModal.value = true; showEditTimeEntryModal.value = true;
} }
// Snap a dayjs time down to the previous snap boundary (for start times)
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');
}
// Snap a dayjs time up to the next snap boundary (for end times)
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');
}
// --- Visual snap (composable) ---
const {
startDragSnap: startVisualDragSnap,
startResizeSnap: startVisualResizeSnap,
stop: stopVisualSnap,
} = useVisualSnap({
calendarRef,
snapMinutes: () => calendarSettings.value.snapMinutes,
slotMinutes: () => calendarSettings.value.slotMinutes,
formatDuration: (seconds) =>
formatHumanReadableDuration(
seconds,
organization?.value?.interval_format,
organization?.value?.number_format
),
});
async function handleEventDrop(arg: EventDropArg) { async function handleEventDrop(arg: EventDropArg) {
stopVisualSnap();
const ext = arg.event.extendedProps as CalendarExtendedProps; const ext = arg.event.extendedProps as CalendarExtendedProps;
const timeEntry = ext.timeEntry; const timeEntry = ext.timeEntry;
if (!arg.event.start || !arg.event.end) return; if (!arg.event.start || !arg.event.end) return;
// Running entries have no end time — can't compute duration for drop
if (!timeEntry.end) return;
const snap = calendarSettings.value.snapMinutes;
const startLocal = getDayJsInstance()(arg.event.start.toISOString())
.utc()
.tz(getUserTimezone(), true)
.second(0);
const snappedStart = snapStartToGrid(startLocal, snap);
const durationMs = getLocalizedDayJs(timeEntry.end).diff(getLocalizedDayJs(timeEntry.start));
const snappedEnd = snappedStart.add(durationMs, 'millisecond');
// Set FC event to snapped position immediately to avoid flash
arg.event.setDates(snappedStart.utc(true).toDate(), snappedEnd.utc(true).toDate());
const updatedTimeEntry = { const updatedTimeEntry = {
...timeEntry, ...timeEntry,
start: getDayJsInstance()(arg.event.start.toISOString()) start: snappedStart.utc().format(),
.utc() end: snappedEnd.utc().format(),
.tz(getUserTimezone(), true)
.second(0)
.utc()
.format(),
end: getDayJsInstance()(arg.event.end.toISOString())
.utc()
.tz(getUserTimezone(), true)
.second(0)
.utc()
.format(),
} as TimeEntry; } as TimeEntry;
await props.updateTimeEntry(updatedTimeEntry); await props.updateTimeEntry(updatedTimeEntry);
emit('refresh'); emit('refresh');
} }
async function handleEventResize(arg: EventChangeArg) { async function handleEventResize(arg: EventChangeArg) {
stopVisualSnap();
const ext = arg.event.extendedProps as CalendarExtendedProps; const ext = arg.event.extendedProps as CalendarExtendedProps;
const timeEntry = ext.timeEntry; const timeEntry = ext.timeEntry;
if (!arg.event.start || !arg.event.end) return; if (!arg.event.start || !arg.event.end) return;
const snap = calendarSettings.value.snapMinutes;
const newStartLocal = getDayJsInstance()(arg.event.start.toISOString())
.utc()
.tz(getUserTimezone(), true)
.second(0);
const newEndLocal = getDayJsInstance()(arg.event.end.toISOString())
.utc()
.tz(getUserTimezone(), true)
.second(0);
const origStartLocal = getLocalizedDayJs(timeEntry.start).second(0);
const startChanged = !newStartLocal.isSame(origStartLocal, 'minute');
// Snap only the changed edge once, reuse for both setDates and API update
const snappedStart = startChanged ? snapStartToGrid(newStartLocal, snap) : null;
const snappedEnd = !startChanged && !ext.isRunning ? snapEndToGrid(newEndLocal, snap) : null;
// Set FC event to snapped position immediately to avoid flash.
// Use the original event date for the edge that wasn't resized.
if (snappedStart) {
arg.event.setDates(snappedStart.utc(true).toDate(), arg.oldEvent.end!);
} else if (snappedEnd) {
arg.event.setDates(arg.oldEvent.start!, snappedEnd.utc(true).toDate());
}
const updatedTimeEntry = { const updatedTimeEntry = {
...timeEntry, ...timeEntry,
start: getDayJsInstance()(arg.event.start.toISOString()) start: snappedStart ? snappedStart.utc().format() : timeEntry.start,
.utc() end: ext.isRunning ? null : snappedEnd ? snappedEnd.utc().format() : timeEntry.end,
.tz(getUserTimezone(), true)
.second(0)
.utc()
.format(),
// Preserve null end for running entries
end: ext.isRunning
? null
: getDayJsInstance()(arg.event.end.toISOString())
.utc()
.tz(getUserTimezone(), true)
.second(0)
.utc()
.format(),
} as TimeEntry; } as TimeEntry;
await props.updateTimeEntry(updatedTimeEntry); await props.updateTimeEntry(updatedTimeEntry);
emit('refresh'); emit('refresh');
} }
const calendarOptions = computed(() => ({ const calendarOptions = computed(() => {
const s = calendarSettings.value;
return {
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin, activityStatusPlugin], plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin, activityStatusPlugin],
initialView: 'timeGridWeek', initialView: 'timeGridWeek',
headerToolbar: { headerToolbar: {
@@ -285,9 +363,9 @@ const calendarOptions = computed(() => ({
right: 'timeGridWeek,timeGridDay', right: 'timeGridWeek,timeGridDay',
}, },
height: 'parent', height: 'parent',
slotMinTime: '00:00:00', slotMinTime: formatDuration(s.startHour * 3600),
slotMaxTime: '24:00:00', slotMaxTime: formatDuration(s.endHour * 3600),
slotDuration: '00:15:00', slotDuration: formatDuration(s.slotMinutes * 60),
slotLabelInterval: '01:00:00', slotLabelInterval: '01:00:00',
slotLabelFormat: getSlotLabelFormat(), slotLabelFormat: getSlotLabelFormat(),
snapDuration: '00:01:00', snapDuration: '00:01:00',
@@ -304,13 +382,16 @@ const calendarOptions = computed(() => ({
eventStartEditable: true, eventStartEditable: true,
select: handleDateSelect, select: handleDateSelect,
eventClick: handleEventClick, eventClick: handleEventClick,
eventDragStart: startVisualDragSnap,
eventDrop: handleEventDrop, eventDrop: handleEventDrop,
eventResizeStart: startVisualResizeSnap,
eventResize: handleEventResize, eventResize: handleEventResize,
datesSet: emitDatesChange, datesSet: emitDatesChange,
events: events.value, events: events.value,
activityPeriods: props.activityPeriods || [], activityPeriods: props.activityPeriods || [],
})); };
});
watch(showCreateTimeEntryModal, (value) => { watch(showCreateTimeEntryModal, (value) => {
if (!value) { if (!value) {
@@ -376,7 +457,6 @@ onActivated(() => {
}); });
onUnmounted(() => { onUnmounted(() => {
// Clean up interval
if (currentTimeInterval) { if (currentTimeInterval) {
clearInterval(currentTimeInterval); clearInterval(currentTimeInterval);
currentTimeInterval = null; currentTimeInterval = null;
@@ -424,6 +504,11 @@ onUnmounted(() => {
:clients="clients" :clients="clients"
:currency="currency" :currency="currency"
:can-create-project="canCreateProject" /> :can-create-project="canCreateProject" />
<div class="calendar-settings-trigger">
<CalendarSettingsPopover
:settings="calendarSettings"
@update:settings="onSettingsUpdate" />
</div>
<FullCalendar ref="calendarRef" class="fullcalendar" :options="calendarOptions"> <FullCalendar ref="calendarRef" class="fullcalendar" :options="calendarOptions">
<template #eventContent="arg"> <template #eventContent="arg">
<FullCalendarEventContent <FullCalendarEventContent
@@ -458,6 +543,13 @@ onUnmounted(() => {
</template> </template>
<style scoped> <style scoped>
.calendar-settings-trigger {
position: absolute;
top: 0.5rem;
right: 0.5rem;
z-index: 20;
}
.fullcalendar { .fullcalendar {
height: 100%; height: 100%;
--fc-border-color: var(--border); --fc-border-color: var(--border);
@@ -482,6 +574,7 @@ onUnmounted(() => {
.fullcalendar :deep(.fc-toolbar) { .fullcalendar :deep(.fc-toolbar) {
background-color: var(--background); background-color: var(--background);
padding: 0.5rem; padding: 0.5rem;
padding-right: 2.75rem;
margin-bottom: 0; margin-bottom: 0;
} }
@@ -580,36 +673,65 @@ onUnmounted(() => {
line-height: 1.2; line-height: 1.2;
} }
/* Enhanced FullCalendar resize handles */ /* Resize handle hit areas */
.fullcalendar :deep(.fc-event-resizer) { .fullcalendar :deep(.fc-event-resizer) {
position: absolute; position: absolute;
z-index: 99; z-index: 99;
background: '#FFF';
border-radius: 2px;
width: 100%; width: 100%;
height: 4px; height: 12px;
left: 0; left: 0;
transition: all 0.2s ease; cursor: row-resize;
display: flex;
align-items: center;
justify-content: center;
opacity: 0; opacity: 0;
transition: opacity 0.15s ease;
} }
.fullcalendar :deep(.fc-event-resizer-start) { .fullcalendar :deep(.fc-event-resizer-start) {
top: -2px; top: -2px;
cursor: n-resize;
} }
.fullcalendar :deep(.fc-event-resizer-end) { .fullcalendar :deep(.fc-event-resizer-end) {
bottom: -2px; bottom: -2px;
cursor: s-resize; }
/* Visual grip indicator */
.fullcalendar :deep(.fc-event-resizer::after) {
content: '';
width: 24px;
height: 3px;
border-radius: 1.5px;
background: rgba(255, 255, 255, 0.6);
transition: background 0.15s ease;
} }
.fullcalendar :deep(.fc-event:hover .fc-event-resizer) { .fullcalendar :deep(.fc-event:hover .fc-event-resizer) {
opacity: 1; opacity: 1;
} }
.fullcalendar :deep(.fc-event-resizer:hover) { .fullcalendar :deep(.fc-event-resizer:hover::after) {
background: '#FFF'; background: rgba(255, 255, 255, 0.9);
height: 6px; }
/* Keep resize cursor during active resize */
.fullcalendar :deep(.fc-event-resizing),
.fullcalendar :deep(.fc-event-resizing .fc-event-resizer) {
cursor: row-resize !important;
}
/* Keep event in hover state while resizing */
.fullcalendar :deep(.fc-event-resizing) {
opacity: 1;
box-shadow: var(--theme-shadow-dropdown);
}
.fullcalendar :deep(.fc-event-resizing .fc-event-resizer) {
opacity: 1;
}
.fullcalendar :deep(.fc-event-resizing .fc-event-resizer::after) {
background: rgba(255, 255, 255, 0.9);
} }
/* Update the earlier hover rule to include the shadow */ /* Update the earlier hover rule to include the shadow */
@@ -632,6 +754,10 @@ onUnmounted(() => {
border: 1px solid var(--primary); border: 1px solid var(--primary);
} }
.fullcalendar :deep(.fc-event-mirror) {
pointer-events: none;
}
.fullcalendar :deep(.fc-scrollgrid) { .fullcalendar :deep(.fc-scrollgrid) {
border: 1px solid var(--border); border: 1px solid var(--border);
border-left: 1px solid transparent; border-left: 1px solid transparent;
@@ -763,3 +889,11 @@ onUnmounted(() => {
border-bottom-right-radius: 0px; border-bottom-right-radius: 0px;
} }
</style> </style>
<style>
/* Global cursor override during resize — must be unscoped to affect body */
body.fc-resizing-active,
body.fc-resizing-active * {
cursor: row-resize !important;
}
</style>

View File

@@ -0,0 +1,6 @@
export interface CalendarSettings {
snapMinutes: number;
startHour: number;
endHour: number;
slotMinutes: number;
}

View File

@@ -0,0 +1,189 @@
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 };
}
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('.fc-event-main')?.querySelector('div:last-child');
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;
startLoop((calendarEl, snapPx) => {
const { mirror, harness } = findMirrorHarness(calendarEl);
if (!harness) return;
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.ceil(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.floor(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,
};
}

View File

@@ -36,6 +36,7 @@ import TimeEntryEditModal from './TimeEntry/TimeEntryEditModal.vue';
import FullCalendarEventContent from './FullCalendar/FullCalendarEventContent.vue'; import FullCalendarEventContent from './FullCalendar/FullCalendarEventContent.vue';
import FullCalendarDayHeader from './FullCalendar/FullCalendarDayHeader.vue'; import FullCalendarDayHeader from './FullCalendar/FullCalendarDayHeader.vue';
import TimeEntryCalendar from './FullCalendar/TimeEntryCalendar.vue'; import TimeEntryCalendar from './FullCalendar/TimeEntryCalendar.vue';
import CalendarSettingsPopover from './FullCalendar/CalendarSettingsPopover.vue';
import DateRangePicker from './Input/DateRangePicker.vue'; import DateRangePicker from './Input/DateRangePicker.vue';
import TimezoneMismatchModal from './TimezoneMismatchModal.vue'; import TimezoneMismatchModal from './TimezoneMismatchModal.vue';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip/index'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip/index';
@@ -59,6 +60,7 @@ import {
} from './field/index'; } from './field/index';
export type { FieldVariants } from './field/index'; export type { FieldVariants } from './field/index';
export type { ActivityPeriod } from './FullCalendar/idleStatusPlugin'; export type { ActivityPeriod } from './FullCalendar/idleStatusPlugin';
export type { CalendarSettings } from './FullCalendar/calendarSettings';
export type { export type {
CommandPaletteCommand, CommandPaletteCommand,
CommandPaletteGroup, CommandPaletteGroup,
@@ -92,6 +94,7 @@ export {
FullCalendarEventContent, FullCalendarEventContent,
FullCalendarDayHeader, FullCalendarDayHeader,
TimeEntryCalendar, TimeEntryCalendar,
CalendarSettingsPopover,
DateRangePicker, DateRangePicker,
TimezoneMismatchModal, TimezoneMismatchModal,
Tooltip, Tooltip,