Compare commits

..

14 Commits

Author SHA1 Message Date
Gregor Vostrak
192c8c3b88 fix IDOR private projects 2026-03-19 13:52:28 +01:00
Gregor Vostrak
6218ffceb5 update composer dependencies 2026-03-03 12:27:42 +01:00
Gregor Vostrak
ba32be0543 update npm dependencies 2026-03-02 18:19:11 +01:00
Gregor Vostrak
bd817db06f only use xsrf token for organization requests 2026-03-02 17:18:21 +01:00
Gregor Vostrak
97f4bce676 bump retries and wait for networkidle in retry 2026-03-02 17:18:21 +01:00
Gregor Vostrak
6962b668fb add retries to api data token setup and xsrf token fallback 2026-03-02 17:18:21 +01:00
Gregor Vostrak
be8091296c use api tokens to create e2e test data 2026-03-02 17:18:21 +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
Gregor Vostrak
d2f3fe411a add missing query invalidation after report create 2026-02-18 23:58:39 +01:00
15 changed files with 2272 additions and 1182 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.

View File

@@ -78,7 +78,7 @@ class ProjectController extends Controller
*/
public function show(Organization $organization, Project $project): JsonResource
{
$this->checkPermission($organization, 'projects:view', $project);
$this->checkPermission($organization, 'projects:view:all', $project);
// Note: There is currently no need to check if a user is a member of the project,
// since this is only relevant for users with the role "employee" and they can not access this endpoint.

2066
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,
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);

420
package-lock.json generated
View File

@@ -138,9 +138,9 @@
}
},
"node_modules/@apidevtools/swagger-parser/node_modules/ajv": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1195,29 +1195,6 @@
"@swc/helpers": "^0.5.0"
}
},
"node_modules/@isaacs/balanced-match": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
"integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@isaacs/brace-expansion": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz",
"integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@isaacs/balanced-match": "^4.0.1"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -1283,22 +1260,22 @@
}
},
"node_modules/@microsoft/api-extractor": {
"version": "7.56.0",
"resolved": "https://registry.npmjs.org/@microsoft/api-extractor/-/api-extractor-7.56.0.tgz",
"integrity": "sha512-H0V69QG5jIb9Ayx35NVBv2lOgFSS3q+Eab2oyGEy0POL3ovYPST+rCNPbwYoczOZXNG8IKjWUmmAMxmDTsXlQA==",
"version": "7.57.6",
"resolved": "https://registry.npmjs.org/@microsoft/api-extractor/-/api-extractor-7.57.6.tgz",
"integrity": "sha512-0rFv/D8Grzw1Mjs2+8NGUR+o4h9LVm5zKRtMeWnpdB5IMJF4TeHCL1zR5LMCIudkOvyvjbhMG5Wjs0B5nqsrRQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@microsoft/api-extractor-model": "7.32.2",
"@microsoft/api-extractor-model": "7.33.4",
"@microsoft/tsdoc": "~0.16.0",
"@microsoft/tsdoc-config": "~0.18.0",
"@rushstack/node-core-library": "5.19.1",
"@rushstack/rig-package": "0.6.0",
"@rushstack/terminal": "0.21.0",
"@rushstack/ts-command-line": "5.1.7",
"@microsoft/tsdoc-config": "~0.18.1",
"@rushstack/node-core-library": "5.20.3",
"@rushstack/rig-package": "0.7.2",
"@rushstack/terminal": "0.22.3",
"@rushstack/ts-command-line": "5.3.3",
"diff": "~8.0.2",
"lodash": "~4.17.15",
"minimatch": "10.0.3",
"lodash": "~4.17.23",
"minimatch": "10.2.1",
"resolve": "~1.22.1",
"semver": "~7.5.4",
"source-map": "~0.6.1",
@@ -1309,15 +1286,38 @@
}
},
"node_modules/@microsoft/api-extractor-model": {
"version": "7.32.2",
"resolved": "https://registry.npmjs.org/@microsoft/api-extractor-model/-/api-extractor-model-7.32.2.tgz",
"integrity": "sha512-Ussc25rAalc+4JJs9HNQE7TuO9y6jpYQX9nWD1DhqUzYPBr3Lr7O9intf+ZY8kD5HnIqeIRJX7ccCT0QyBy2Ww==",
"version": "7.33.4",
"resolved": "https://registry.npmjs.org/@microsoft/api-extractor-model/-/api-extractor-model-7.33.4.tgz",
"integrity": "sha512-u1LTaNTikZAQ9uK6KG1Ms7nvNedsnODnspq/gH2dcyETWvH4hVNGNDvRAEutH66kAmxA4/necElqGNs1FggC8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@microsoft/tsdoc": "~0.16.0",
"@microsoft/tsdoc-config": "~0.18.0",
"@rushstack/node-core-library": "5.19.1"
"@microsoft/tsdoc-config": "~0.18.1",
"@rushstack/node-core-library": "5.20.3"
}
},
"node_modules/@microsoft/api-extractor/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@microsoft/api-extractor/node_modules/brace-expansion": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@microsoft/api-extractor/node_modules/lru-cache": {
@@ -1334,13 +1334,13 @@
}
},
"node_modules/@microsoft/api-extractor/node_modules/minimatch": {
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz",
"integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==",
"version": "10.2.1",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.1.tgz",
"integrity": "sha512-MClCe8IL5nRRmawL6ib/eT4oLyeKMGCghibcDWK+J0hh0Q8kqSdia6BvbRMVk6mPa6WqUa5uR2oxt6C5jd533A==",
"dev": true,
"license": "ISC",
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/brace-expansion": "^5.0.0"
"brace-expansion": "^5.0.2"
},
"engines": {
"node": "20 || >=22"
@@ -1394,29 +1394,29 @@
"license": "MIT"
},
"node_modules/@microsoft/tsdoc-config": {
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/@microsoft/tsdoc-config/-/tsdoc-config-0.18.0.tgz",
"integrity": "sha512-8N/vClYyfOH+l4fLkkr9+myAoR6M7akc8ntBJ4DJdWH2b09uVfr71+LTMpNyG19fNqWDg8KEDZhx5wxuqHyGjw==",
"version": "0.18.1",
"resolved": "https://registry.npmjs.org/@microsoft/tsdoc-config/-/tsdoc-config-0.18.1.tgz",
"integrity": "sha512-9brPoVdfN9k9g0dcWkFeA7IH9bbcttzDJlXvkf8b2OBzd5MueR1V2wkKBL0abn0otvmkHJC6aapBOTJDDeMCZg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@microsoft/tsdoc": "0.16.0",
"ajv": "~8.12.0",
"ajv": "~8.18.0",
"jju": "~1.4.0",
"resolve": "~1.22.2"
}
},
"node_modules/@microsoft/tsdoc-config/node_modules/ajv": {
"version": "8.12.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz",
"integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==",
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2",
"uri-js": "^4.2.2"
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
@@ -1536,9 +1536,9 @@
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz",
"integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
"cpu": [
"arm"
],
@@ -1549,9 +1549,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz",
"integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
"cpu": [
"arm64"
],
@@ -1562,9 +1562,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz",
"integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
"cpu": [
"arm64"
],
@@ -1575,9 +1575,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz",
"integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
"cpu": [
"x64"
],
@@ -1588,9 +1588,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz",
"integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
"cpu": [
"arm64"
],
@@ -1601,9 +1601,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz",
"integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
"cpu": [
"x64"
],
@@ -1614,9 +1614,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz",
"integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
"cpu": [
"arm"
],
@@ -1627,9 +1627,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz",
"integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
"cpu": [
"arm"
],
@@ -1640,9 +1640,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz",
"integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
"cpu": [
"arm64"
],
@@ -1653,9 +1653,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz",
"integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
"cpu": [
"arm64"
],
@@ -1666,9 +1666,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz",
"integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
"cpu": [
"loong64"
],
@@ -1679,9 +1679,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz",
"integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
"cpu": [
"loong64"
],
@@ -1692,9 +1692,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz",
"integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
"cpu": [
"ppc64"
],
@@ -1705,9 +1705,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz",
"integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
"cpu": [
"ppc64"
],
@@ -1718,9 +1718,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz",
"integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
"cpu": [
"riscv64"
],
@@ -1731,9 +1731,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz",
"integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
"cpu": [
"riscv64"
],
@@ -1744,9 +1744,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz",
"integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
"cpu": [
"s390x"
],
@@ -1757,9 +1757,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz",
"integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
"cpu": [
"x64"
],
@@ -1770,9 +1770,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz",
"integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
"integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
"cpu": [
"x64"
],
@@ -1783,9 +1783,9 @@
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz",
"integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
"cpu": [
"x64"
],
@@ -1796,9 +1796,9 @@
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz",
"integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
"cpu": [
"arm64"
],
@@ -1809,9 +1809,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz",
"integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
"cpu": [
"arm64"
],
@@ -1822,9 +1822,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz",
"integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
"cpu": [
"ia32"
],
@@ -1835,9 +1835,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz",
"integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
"cpu": [
"x64"
],
@@ -1848,9 +1848,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz",
"integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
"cpu": [
"x64"
],
@@ -1867,13 +1867,13 @@
"license": "MIT"
},
"node_modules/@rushstack/node-core-library": {
"version": "5.19.1",
"resolved": "https://registry.npmjs.org/@rushstack/node-core-library/-/node-core-library-5.19.1.tgz",
"integrity": "sha512-ESpb2Tajlatgbmzzukg6zyAhH+sICqJR2CNXNhXcEbz6UGCQfrKCtkxOpJTftWc8RGouroHG0Nud1SJAszvpmA==",
"version": "5.20.3",
"resolved": "https://registry.npmjs.org/@rushstack/node-core-library/-/node-core-library-5.20.3.tgz",
"integrity": "sha512-95JgEPq2k7tHxhF9/OJnnyHDXfC9cLhhta0An/6MlkDsX2A6dTzDrTUG18vx4vjc280V0fi0xDH9iQczpSuWsw==",
"dev": true,
"license": "MIT",
"dependencies": {
"ajv": "~8.13.0",
"ajv": "~8.18.0",
"ajv-draft-04": "~1.0.0",
"ajv-formats": "~3.0.1",
"fs-extra": "~11.3.0",
@@ -1892,16 +1892,16 @@
}
},
"node_modules/@rushstack/node-core-library/node_modules/ajv": {
"version": "8.13.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.13.0.tgz",
"integrity": "sha512-PRA911Blj99jR5RMeTunVbNXMF6Lp4vZXnk5GQjcnUWUTsrXtekg/pnmFFI2u/I36Y/2bITGS30GZCXei6uNkA==",
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2",
"uri-js": "^4.4.1"
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
@@ -1982,9 +1982,9 @@
"license": "ISC"
},
"node_modules/@rushstack/problem-matcher": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@rushstack/problem-matcher/-/problem-matcher-0.1.1.tgz",
"integrity": "sha512-Fm5XtS7+G8HLcJHCWpES5VmeMyjAKaWeyZU5qPzZC+22mPlJzAsOxymHiWIfuirtPckX3aptWws+K2d0BzniJA==",
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/@rushstack/problem-matcher/-/problem-matcher-0.2.1.tgz",
"integrity": "sha512-gulfhBs6n+I5b7DvjKRfhMGyUejtSgOHTclF/eONr8hcgF1APEDjhxIsfdUYYMzC3rvLwGluqLjbwCFZ8nxrog==",
"dev": true,
"license": "MIT",
"peerDependencies": {
@@ -1997,9 +1997,9 @@
}
},
"node_modules/@rushstack/rig-package": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@rushstack/rig-package/-/rig-package-0.6.0.tgz",
"integrity": "sha512-ZQmfzsLE2+Y91GF15c65L/slMRVhF6Hycq04D4TwtdGaUAbIXXg9c5pKA5KFU7M4QMaihoobp9JJYpYcaY3zOw==",
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/@rushstack/rig-package/-/rig-package-0.7.2.tgz",
"integrity": "sha512-9XbFWuqMYcHUso4mnETfhGVUSaADBRj6HUAAEYk50nMPn8WRICmBuCphycQGNB3duIR6EEZX3Xj3SYc2XiP+9A==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2008,14 +2008,14 @@
}
},
"node_modules/@rushstack/terminal": {
"version": "0.21.0",
"resolved": "https://registry.npmjs.org/@rushstack/terminal/-/terminal-0.21.0.tgz",
"integrity": "sha512-cLaI4HwCNYmknM5ns4G+drqdEB6q3dCPV423+d3TZeBusYSSm09+nR7CnhzJMjJqeRcdMAaLnrA4M/3xDz4R3w==",
"version": "0.22.3",
"resolved": "https://registry.npmjs.org/@rushstack/terminal/-/terminal-0.22.3.tgz",
"integrity": "sha512-gHC9pIMrUPzAbBiI4VZMU7Q+rsCzb8hJl36lFIulIzoceKotyKL3Rd76AZ2CryCTKEg+0bnTj406HE5YY5OQvw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rushstack/node-core-library": "5.19.1",
"@rushstack/problem-matcher": "0.1.1",
"@rushstack/node-core-library": "5.20.3",
"@rushstack/problem-matcher": "0.2.1",
"supports-color": "~8.1.1"
},
"peerDependencies": {
@@ -2044,13 +2044,13 @@
}
},
"node_modules/@rushstack/ts-command-line": {
"version": "5.1.7",
"resolved": "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-5.1.7.tgz",
"integrity": "sha512-Ugwl6flarZcL2nqH5IXFYk3UR3mBVDsVFlCQW/Oaqidvdb/5Ota6b/Z3JXWIdqV3rOR2/JrYoAHanWF5rgenXA==",
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-5.3.3.tgz",
"integrity": "sha512-c+ltdcvC7ym+10lhwR/vWiOhsrm/bP3By2VsFcs5qTKv+6tTmxgbVrtJ5NdNjANiV5TcmOZgUN+5KYQ4llsvEw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rushstack/terminal": "0.21.0",
"@rushstack/terminal": "0.22.3",
"@types/argparse": "1.0.38",
"argparse": "~1.0.9",
"string-argv": "~0.3.1"
@@ -2661,12 +2661,12 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
@@ -3131,9 +3131,9 @@
}
},
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.1",
@@ -3165,9 +3165,9 @@
}
},
"node_modules/ajv-formats/node_modules/ajv": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3297,13 +3297,13 @@
}
},
"node_modules/axios": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz",
"integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==",
"version": "1.13.6",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^1.1.0"
}
},
@@ -5071,9 +5071,9 @@
}
},
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
@@ -5910,9 +5910,9 @@
}
},
"node_modules/qs": {
"version": "6.14.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
"integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
@@ -6200,9 +6200,9 @@
"license": "MIT"
},
"node_modules/rollup": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
"integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
"license": "MIT",
"dependencies": {
"@types/estree": "1.0.8"
@@ -6215,31 +6215,31 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.57.1",
"@rollup/rollup-android-arm64": "4.57.1",
"@rollup/rollup-darwin-arm64": "4.57.1",
"@rollup/rollup-darwin-x64": "4.57.1",
"@rollup/rollup-freebsd-arm64": "4.57.1",
"@rollup/rollup-freebsd-x64": "4.57.1",
"@rollup/rollup-linux-arm-gnueabihf": "4.57.1",
"@rollup/rollup-linux-arm-musleabihf": "4.57.1",
"@rollup/rollup-linux-arm64-gnu": "4.57.1",
"@rollup/rollup-linux-arm64-musl": "4.57.1",
"@rollup/rollup-linux-loong64-gnu": "4.57.1",
"@rollup/rollup-linux-loong64-musl": "4.57.1",
"@rollup/rollup-linux-ppc64-gnu": "4.57.1",
"@rollup/rollup-linux-ppc64-musl": "4.57.1",
"@rollup/rollup-linux-riscv64-gnu": "4.57.1",
"@rollup/rollup-linux-riscv64-musl": "4.57.1",
"@rollup/rollup-linux-s390x-gnu": "4.57.1",
"@rollup/rollup-linux-x64-gnu": "4.57.1",
"@rollup/rollup-linux-x64-musl": "4.57.1",
"@rollup/rollup-openbsd-x64": "4.57.1",
"@rollup/rollup-openharmony-arm64": "4.57.1",
"@rollup/rollup-win32-arm64-msvc": "4.57.1",
"@rollup/rollup-win32-ia32-msvc": "4.57.1",
"@rollup/rollup-win32-x64-gnu": "4.57.1",
"@rollup/rollup-win32-x64-msvc": "4.57.1",
"@rollup/rollup-android-arm-eabi": "4.59.0",
"@rollup/rollup-android-arm64": "4.59.0",
"@rollup/rollup-darwin-arm64": "4.59.0",
"@rollup/rollup-darwin-x64": "4.59.0",
"@rollup/rollup-freebsd-arm64": "4.59.0",
"@rollup/rollup-freebsd-x64": "4.59.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
"@rollup/rollup-linux-arm-musleabihf": "4.59.0",
"@rollup/rollup-linux-arm64-gnu": "4.59.0",
"@rollup/rollup-linux-arm64-musl": "4.59.0",
"@rollup/rollup-linux-loong64-gnu": "4.59.0",
"@rollup/rollup-linux-loong64-musl": "4.59.0",
"@rollup/rollup-linux-ppc64-gnu": "4.59.0",
"@rollup/rollup-linux-ppc64-musl": "4.59.0",
"@rollup/rollup-linux-riscv64-gnu": "4.59.0",
"@rollup/rollup-linux-riscv64-musl": "4.59.0",
"@rollup/rollup-linux-s390x-gnu": "4.59.0",
"@rollup/rollup-linux-x64-gnu": "4.59.0",
"@rollup/rollup-linux-x64-musl": "4.59.0",
"@rollup/rollup-openbsd-x64": "4.59.0",
"@rollup/rollup-openharmony-arm64": "4.59.0",
"@rollup/rollup-win32-arm64-msvc": "4.59.0",
"@rollup/rollup-win32-ia32-msvc": "4.59.0",
"@rollup/rollup-win32-x64-gnu": "4.59.0",
"@rollup/rollup-win32-x64-msvc": "4.59.0",
"fsevents": "~2.3.2"
}
},
@@ -7149,13 +7149,13 @@
}
},
"node_modules/vite-plugin-dts/node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
@@ -7434,7 +7434,7 @@
},
"resources/js/packages/ui": {
"name": "@solidtime/ui",
"version": "0.0.15",
"version": "0.0.16",
"license": "AGPL-3.0",
"devDependencies": {
"vite-plugin-dts": "^4.0.3"

View File

@@ -6,7 +6,7 @@ import { ref } from 'vue';
import PrimaryButton from '../../../packages/ui/src/Buttons/PrimaryButton.vue';
import { Field, FieldLabel } from '@/packages/ui/src/field';
import type { CreateReportBody, CreateReportBodyProperties } from '@/packages/api/src';
import { useMutation } from '@tanstack/vue-query';
import { useMutation, useQueryClient } from '@tanstack/vue-query';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { api } from '@/packages/api/src';
import { Checkbox } from '@/packages/ui/src';
@@ -17,6 +17,7 @@ import { router } from '@inertiajs/vue3';
const show = defineModel('show', { default: false });
const saving = ref(false);
const queryClient = useQueryClient();
const createReportMutation = useMutation({
mutationFn: async (report: CreateReportBody) => {
@@ -30,6 +31,11 @@ const createReportMutation = useMutation({
},
});
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['reports'],
});
},
});
const props = defineProps<{

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,
onUnmounted,
} from 'vue';
import { useLocalStorage } from '@vueuse/core';
import chroma from 'chroma-js';
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 { LoadingSpinner, TimeEntryCreateModal, TimeEntryEditModal } from '..';
import FullCalendarEventContent from './FullCalendarEventContent.vue';
import FullCalendarDayHeader from './FullCalendarDayHeader.vue';
import CalendarSettingsPopover from './CalendarSettingsPopover.vue';
import type { CalendarSettings } from './calendarSettings';
import { useVisualSnap } from './useVisualSnap';
import activityStatusPlugin, {
type ActivityPeriod,
renderActivityStatusBoxes,
@@ -81,6 +90,22 @@ const selectedTimeEntry = ref<TimeEntry | 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
const currentTime = ref(getDayJsInstance()());
let currentTimeInterval: ReturnType<typeof setInterval> | null = null;
@@ -204,16 +229,19 @@ function emitDatesChange(arg: DatesSetArg) {
}
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()
.tz(getUserTimezone(), true)
.utc();
const endTime = getDayJsInstance()(arg.end.toISOString())
.utc()
.tz(getUserTimezone(), true)
.utc();
newEventStart.value = startTime;
newEventEnd.value = endTime;
.tz(getUserTimezone(), true);
const endLocal = getDayJsInstance()(arg.end.toISOString()).utc().tz(getUserTimezone(), true);
const snappedStart = snapStartToGrid(startLocal, snap);
let snappedEnd = snapEndToGrid(endLocal, snap);
if (!snappedEnd.isAfter(snappedStart)) {
snappedEnd = snappedStart.add(snap, 'minute');
}
newEventStart.value = snappedStart.utc();
newEventEnd.value = snappedEnd.utc();
showCreateTimeEntryModal.value = true;
}
@@ -227,90 +255,143 @@ function handleEventClick(arg: EventClickArg) {
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) {
stopVisualSnap();
const ext = arg.event.extendedProps as CalendarExtendedProps;
const timeEntry = ext.timeEntry;
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 = {
...timeEntry,
start: getDayJsInstance()(arg.event.start.toISOString())
.utc()
.tz(getUserTimezone(), true)
.second(0)
.utc()
.format(),
end: getDayJsInstance()(arg.event.end.toISOString())
.utc()
.tz(getUserTimezone(), true)
.second(0)
.utc()
.format(),
start: snappedStart.utc().format(),
end: snappedEnd.utc().format(),
} as TimeEntry;
await props.updateTimeEntry(updatedTimeEntry);
emit('refresh');
}
async function handleEventResize(arg: EventChangeArg) {
stopVisualSnap();
const ext = arg.event.extendedProps as CalendarExtendedProps;
const timeEntry = ext.timeEntry;
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 = {
...timeEntry,
start: getDayJsInstance()(arg.event.start.toISOString())
.utc()
.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(),
start: snappedStart ? snappedStart.utc().format() : timeEntry.start,
end: ext.isRunning ? null : snappedEnd ? snappedEnd.utc().format() : timeEntry.end,
} as TimeEntry;
await props.updateTimeEntry(updatedTimeEntry);
emit('refresh');
}
const calendarOptions = computed(() => ({
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin, activityStatusPlugin],
initialView: 'timeGridWeek',
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'timeGridWeek,timeGridDay',
},
height: 'parent',
slotMinTime: '00:00:00',
slotMaxTime: '24:00:00',
slotDuration: '00:15:00',
slotLabelInterval: '01:00:00',
slotLabelFormat: getSlotLabelFormat(),
snapDuration: '00:01:00',
firstDay: getFirstDay(),
allDaySlot: false,
nowIndicator: true,
eventMinHeight: 1,
selectable: true,
selectMirror: true,
editable: true,
eventResizableFromStart: true,
eventDurationEditable: true,
timeZone: getUserTimezone(),
eventStartEditable: true,
select: handleDateSelect,
eventClick: handleEventClick,
eventDrop: handleEventDrop,
eventResize: handleEventResize,
datesSet: emitDatesChange,
const calendarOptions = computed(() => {
const s = calendarSettings.value;
events: events.value,
activityPeriods: props.activityPeriods || [],
}));
return {
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin, activityStatusPlugin],
initialView: 'timeGridWeek',
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'timeGridWeek,timeGridDay',
},
height: 'parent',
slotMinTime: formatDuration(s.startHour * 3600),
slotMaxTime: formatDuration(s.endHour * 3600),
slotDuration: formatDuration(s.slotMinutes * 60),
slotLabelInterval: '01:00:00',
slotLabelFormat: getSlotLabelFormat(),
snapDuration: '00:01:00',
firstDay: getFirstDay(),
allDaySlot: false,
nowIndicator: true,
eventMinHeight: 1,
selectable: true,
selectMirror: true,
editable: true,
eventResizableFromStart: true,
eventDurationEditable: true,
timeZone: getUserTimezone(),
eventStartEditable: true,
select: handleDateSelect,
eventClick: handleEventClick,
eventDragStart: startVisualDragSnap,
eventDrop: handleEventDrop,
eventResizeStart: startVisualResizeSnap,
eventResize: handleEventResize,
datesSet: emitDatesChange,
events: events.value,
activityPeriods: props.activityPeriods || [],
};
});
watch(showCreateTimeEntryModal, (value) => {
if (!value) {
@@ -376,7 +457,6 @@ onActivated(() => {
});
onUnmounted(() => {
// Clean up interval
if (currentTimeInterval) {
clearInterval(currentTimeInterval);
currentTimeInterval = null;
@@ -424,6 +504,11 @@ onUnmounted(() => {
:clients="clients"
:currency="currency"
:can-create-project="canCreateProject" />
<div class="calendar-settings-trigger">
<CalendarSettingsPopover
:settings="calendarSettings"
@update:settings="onSettingsUpdate" />
</div>
<FullCalendar ref="calendarRef" class="fullcalendar" :options="calendarOptions">
<template #eventContent="arg">
<FullCalendarEventContent
@@ -458,6 +543,13 @@ onUnmounted(() => {
</template>
<style scoped>
.calendar-settings-trigger {
position: absolute;
top: 0.5rem;
right: 0.5rem;
z-index: 20;
}
.fullcalendar {
height: 100%;
--fc-border-color: var(--border);
@@ -482,6 +574,7 @@ onUnmounted(() => {
.fullcalendar :deep(.fc-toolbar) {
background-color: var(--background);
padding: 0.5rem;
padding-right: 2.75rem;
margin-bottom: 0;
}
@@ -580,36 +673,65 @@ onUnmounted(() => {
line-height: 1.2;
}
/* Enhanced FullCalendar resize handles */
/* Resize handle hit areas */
.fullcalendar :deep(.fc-event-resizer) {
position: absolute;
z-index: 99;
background: '#FFF';
border-radius: 2px;
width: 100%;
height: 4px;
height: 12px;
left: 0;
transition: all 0.2s ease;
cursor: row-resize;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.15s ease;
}
.fullcalendar :deep(.fc-event-resizer-start) {
top: -2px;
cursor: n-resize;
}
.fullcalendar :deep(.fc-event-resizer-end) {
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) {
opacity: 1;
}
.fullcalendar :deep(.fc-event-resizer:hover) {
background: '#FFF';
height: 6px;
.fullcalendar :deep(.fc-event-resizer:hover::after) {
background: rgba(255, 255, 255, 0.9);
}
/* 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 */
@@ -632,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;
@@ -763,3 +889,11 @@ onUnmounted(() => {
border-bottom-right-radius: 0px;
}
</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 FullCalendarDayHeader from './FullCalendar/FullCalendarDayHeader.vue';
import TimeEntryCalendar from './FullCalendar/TimeEntryCalendar.vue';
import CalendarSettingsPopover from './FullCalendar/CalendarSettingsPopover.vue';
import DateRangePicker from './Input/DateRangePicker.vue';
import TimezoneMismatchModal from './TimezoneMismatchModal.vue';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip/index';
@@ -59,6 +60,7 @@ import {
} from './field/index';
export type { FieldVariants } from './field/index';
export type { ActivityPeriod } from './FullCalendar/idleStatusPlugin';
export type { CalendarSettings } from './FullCalendar/calendarSettings';
export type {
CommandPaletteCommand,
CommandPaletteGroup,
@@ -92,6 +94,7 @@ export {
FullCalendarEventContent,
FullCalendarDayHeader,
TimeEntryCalendar,
CalendarSettingsPopover,
DateRangePicker,
TimezoneMismatchModal,
Tooltip,

View File

@@ -281,6 +281,7 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'projects:view',
'projects:view:all',
]);
$project = Project::factory()->forOrganization($data->organization)->create();
Passport::actingAs($data->user);
@@ -293,6 +294,20 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
$response->assertJsonPath('data.id', $project->getKey());
}
public function test_show_endpoint_fails_if_employee_tries_to_access_private_project_that_they_are_not_a_member_of(): void
{
// Arrange
$data = $this->createUserWithRole(Role::Employee);
$privateProject = Project::factory()->forOrganization($data->organization)->isPrivate()->create();
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.projects.show', [$data->organization->getKey(), $privateProject->getKey()]));
// Assert
$response->assertForbidden();
}
public function test_store_endpoint_fails_if_user_has_no_permission_to_create_projects(): void
{
// Arrange