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
7 changed files with 997 additions and 682 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.
**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.
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

@@ -608,7 +608,7 @@ test('test that billable icon shows dollar sign for USD currency on time entry r
page,
ctx,
}) => {
await updateOrganizationCurrencyViaWeb(ctx, 'USD');
await updateOrganizationCurrencyViaWeb(page, ctx, 'USD');
await goToTimeOverview(page);
await createEmptyTimeEntry(page);
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,
ctx,
}) => {
await updateOrganizationCurrencyViaWeb(ctx, 'EUR');
await updateOrganizationCurrencyViaWeb(page, ctx, 'EUR');
await goToTimeOverview(page);
await createEmptyTimeEntry(page);
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 }) => {
await updateOrganizationCurrencyViaWeb(ctx, 'USD');
await updateOrganizationCurrencyViaWeb(page, ctx, 'USD');
await goToDashboard(page);
await page.waitForLoadState('networkidle');
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 }) => {
await updateOrganizationCurrencyViaWeb(ctx, 'EUR');
await updateOrganizationCurrencyViaWeb(page, ctx, 'EUR');
await goToDashboard(page);
await page.waitForLoadState('networkidle');
const billableButton = page.getByRole('button', { name: 'Non Billable' }).first();

View File

@@ -16,12 +16,59 @@ export interface TestContext {
// Auth helpers
// ──────────────────────────────────────────────────
async function getApiHeaders(page: Page): Promise<Record<string, string>> {
const cookies = await page.context().cookies();
const xsrfCookie = cookies.find((c) => c.name === 'XSRF-TOKEN');
/**
* Create a Passport API token by calling the token endpoint from the browser.
*
* 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 {
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> {
const token = await createApiToken(page);
const request = page.request;
const headers = await getApiHeaders(page);
const headers = bearerHeaders(token);
const orgId = await getOrganizationId(request, headers);
const memberId = await getCurrentMemberId(request, orgId, headers);
return { request: createAuthenticatedRequest(request, headers), orgId, memberId };
@@ -491,11 +540,17 @@ export async function updateOrganizationSettingViaApi(
}
export async function updateOrganizationCurrencyViaWeb(
page: Page,
ctx: TestContext,
currency: string,
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 },
});
expect(response.status()).toBe(200);

View File

@@ -235,8 +235,8 @@ function handleDateSelect(arg: { start: Date; end: Date }) {
.utc()
.tz(getUserTimezone(), true);
const endLocal = getDayJsInstance()(arg.end.toISOString()).utc().tz(getUserTimezone(), true);
const snappedStart = snapToGrid(startLocal, snap);
let snappedEnd = snapToGrid(endLocal, snap);
const snappedStart = snapStartToGrid(startLocal, snap);
let snappedEnd = snapEndToGrid(endLocal, snap);
if (!snappedEnd.isAfter(snappedStart)) {
snappedEnd = snappedStart.add(snap, 'minute');
}
@@ -255,10 +255,17 @@ function handleEventClick(arg: EventClickArg) {
showEditTimeEntryModal.value = true;
}
// Snap a dayjs time to the nearest snap interval boundary
function snapToGrid(time: Dayjs, snapMinutes: number): Dayjs {
// 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.round(minutes / snapMinutes) * snapMinutes;
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');
}
@@ -291,7 +298,7 @@ async function handleEventDrop(arg: EventDropArg) {
.utc()
.tz(getUserTimezone(), true)
.second(0);
const snappedStart = snapToGrid(startLocal, snap);
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
@@ -325,8 +332,8 @@ async function handleEventResize(arg: EventChangeArg) {
const startChanged = !newStartLocal.isSame(origStartLocal, 'minute');
// Snap only the changed edge once, reuse for both setDates and API update
const snappedStart = startChanged ? snapToGrid(newStartLocal, snap) : null;
const snappedEnd = !startChanged && !ext.isRunning ? snapToGrid(newEndLocal, snap) : null;
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.
@@ -747,6 +754,10 @@ onUnmounted(() => {
border: 1px solid var(--primary);
}
.fullcalendar :deep(.fc-event-mirror) {
pointer-events: none;
}
.fullcalendar :deep(.fc-scrollgrid) {
border: 1px solid var(--border);
border-left: 1px solid transparent;

View File

@@ -32,6 +32,9 @@ export function useVisualSnap({
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 };
}
@@ -81,8 +84,8 @@ export function useVisualSnap({
const top = parseFloat(harness.style.top) || 0;
const endPos = -(parseFloat(harness.style.bottom) || 0);
const snappedTop = Math.round(top / snapPx) * snapPx;
const snappedEnd = Math.round(endPos / snapPx) * snapPx;
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';
@@ -99,7 +102,7 @@ export function useVisualSnap({
const top = parseFloat(harness.style.top) || 0;
const endPos = -(parseFloat(harness.style.bottom) || 0);
const height = endPos - top;
const snappedTop = Math.round(top / snapPx) * snapPx;
const snappedTop = Math.floor(top / snapPx) * snapPx;
harness.style.top = snappedTop + 'px';
harness.style.bottom = -(snappedTop + height) + 'px';
});
@@ -135,12 +138,12 @@ export function useVisualSnap({
}
if (resizeEdge === 'bottom') {
const snappedEnd = Math.round(endPos / snapPx) * snapPx;
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.round(top / snapPx) * snapPx;
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);