mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-10-16 17:53:18 +01:00
Compare commits
16 Commits
v0.12.0
...
048b2e0204
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
048b2e0204 | ||
|
|
84cd0d572d | ||
|
|
f37b86f377 | ||
|
|
1e7364fc4b | ||
|
|
8cbc9838c9 | ||
|
|
71c8992e31 | ||
|
|
53d91b65d6 | ||
|
|
0c88a10eb5 | ||
|
|
dd7b23958a | ||
|
|
1eb066f5aa | ||
|
|
b1287c6a0a | ||
|
|
815abb5980 | ||
|
|
e2f859be27 | ||
|
|
3d26fcaefe | ||
|
|
1e73a90f9d | ||
|
|
0f8f906e5c |
2
.env.ci
2
.env.ci
@@ -60,7 +60,7 @@ AUDITING_ENABLED=true
|
|||||||
TELESCOPE_ENABLED=false
|
TELESCOPE_ENABLED=false
|
||||||
|
|
||||||
# Services
|
# Services
|
||||||
GOTENBERG_URL=http://0.0.0.0:3000
|
GOTENBERG_URL=http://localhost:3000
|
||||||
|
|
||||||
# Octane
|
# Octane
|
||||||
OCTANE_SERVER=frankenphp
|
OCTANE_SERVER=frankenphp
|
||||||
|
|||||||
@@ -77,6 +77,9 @@ TELESCOPE_ENABLED=false
|
|||||||
# Services
|
# Services
|
||||||
GOTENBERG_URL=http://gotenberg:3000
|
GOTENBERG_URL=http://gotenberg:3000
|
||||||
|
|
||||||
|
# Octane
|
||||||
|
OCTANE_SERVER=frankenphp
|
||||||
|
|
||||||
# Local setup
|
# Local setup
|
||||||
NGINX_HOST_NAME=solidtime.test
|
NGINX_HOST_NAME=solidtime.test
|
||||||
NETWORK_NAME=reverse-proxy-docker-traefik_routing
|
NETWORK_NAME=reverse-proxy-docker-traefik_routing
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
} from './utils/currentTimeEntry';
|
} from './utils/currentTimeEntry';
|
||||||
import type { Page } from '@playwright/test';
|
import type { Page } from '@playwright/test';
|
||||||
import { newTagResponse } from './utils/tags';
|
import { newTagResponse } from './utils/tags';
|
||||||
import { updateOrganizationCurrencyViaWeb } from './utils/api';
|
import { createProjectViaApi, updateOrganizationCurrencyViaWeb } from './utils/api';
|
||||||
|
|
||||||
// Date picker button name patterns for different date formats
|
// Date picker button name patterns for different date formats
|
||||||
const DATE_DISPLAY_PATTERN = /^\d{4}-\d{2}-\d{2}$|^\d{2}\/\d{2}\/\d{4}$|^\d{2}\.\d{2}\.\d{4}$/;
|
const DATE_DISPLAY_PATTERN = /^\d{4}-\d{2}-\d{2}$|^\d{2}\/\d{2}\/\d{4}$|^\d{2}\.\d{2}\.\d{4}$/;
|
||||||
@@ -368,6 +368,45 @@ test('test that timer started on dashboard is visible on time page', async ({ pa
|
|||||||
await assertThatTimerIsStopped(page);
|
await assertThatTimerIsStopped(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('test that creating a new project from the time tracker dropdown prefills the search text', async ({
|
||||||
|
page,
|
||||||
|
ctx,
|
||||||
|
}) => {
|
||||||
|
const existingProjectName = 'Existing Project ' + Math.floor(Math.random() * 10000);
|
||||||
|
const searchText = 'PrefillProject ' + Math.floor(Math.random() * 10000);
|
||||||
|
|
||||||
|
// Create a project so the dropdown renders (not the "Add new project" button)
|
||||||
|
await createProjectViaApi(ctx, { name: existingProjectName });
|
||||||
|
await goToDashboard(page);
|
||||||
|
|
||||||
|
// Open the project dropdown
|
||||||
|
await page.getByRole('button', { name: 'No Project' }).click();
|
||||||
|
|
||||||
|
// Type a search term that won't match any existing project
|
||||||
|
await page.getByTestId('client_dropdown_search').fill(searchText);
|
||||||
|
|
||||||
|
// Click "Create new Project"
|
||||||
|
await page.getByText('Create new Project').click();
|
||||||
|
|
||||||
|
// Verify the project name input is pre-filled with the search text
|
||||||
|
await expect(page.getByLabel('Project name')).toHaveValue(searchText);
|
||||||
|
|
||||||
|
// Complete project creation to verify full flow works
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
async (response) =>
|
||||||
|
response.url().includes('/projects') &&
|
||||||
|
response.request().method() === 'POST' &&
|
||||||
|
response.status() === 201 &&
|
||||||
|
(await response.json()).data.name === searchText
|
||||||
|
),
|
||||||
|
page.getByRole('button', { name: 'Create Project' }).click(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// The project dropdown should now show the newly created project
|
||||||
|
await expect(page.getByRole('button', { name: searchText })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
test('test that adding a project and tag before starting timer works', async ({ page }) => {
|
test('test that adding a project and tag before starting timer works', async ({ page }) => {
|
||||||
const newTagName = 'TimerTag ' + Math.floor(Math.random() * 10000);
|
const newTagName = 'TimerTag ' + Math.floor(Math.random() * 10000);
|
||||||
await goToDashboard(page);
|
await goToDashboard(page);
|
||||||
|
|||||||
2479
package-lock.json
generated
2479
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
24
package.json
24
package.json
@@ -19,26 +19,26 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.2.0",
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
"@eslint/js": "^9.19.0",
|
"@eslint/js": "^10.0.1",
|
||||||
"@inertiajs/vue3": "^2.0.0",
|
"@inertiajs/vue3": "^3.0.3",
|
||||||
"@playwright/test": "^1.41.1",
|
"@playwright/test": "^1.41.1",
|
||||||
"@tailwindcss/forms": "^0.5.9",
|
"@tailwindcss/forms": "^0.5.9",
|
||||||
"@tailwindcss/typography": "^0.5.15",
|
"@tailwindcss/typography": "^0.5.15",
|
||||||
"@types/chroma-js": "^3.1.0",
|
"@types/chroma-js": "^3.1.0",
|
||||||
"@types/node": "^22.10.10",
|
"@types/node": "^25.6.0",
|
||||||
"@vitejs/plugin-vue": "^6.0.3",
|
"@vitejs/plugin-vue": "^6.0.3",
|
||||||
"@vue/tsconfig": "^0.8.0",
|
"@vue/tsconfig": "^0.8.0",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"axios": "^1.6.4",
|
"axios": "^1.6.4",
|
||||||
"eslint-plugin-unused-imports": "^4.1.4",
|
"eslint-plugin-unused-imports": "^4.1.4",
|
||||||
"laravel-vite-plugin": "^2.1.0",
|
"laravel-vite-plugin": "^3.0.1",
|
||||||
"openapi-zod-client": "^1.16.2",
|
"openapi-zod-client": "^1.16.2",
|
||||||
"postcss": "^8.4.47",
|
"postcss": "^8.4.47",
|
||||||
"postcss-import": "^15.1.0",
|
"postcss-import": "^16.1.1",
|
||||||
"postcss-nesting": "^12.1.5",
|
"postcss-nesting": "^14.0.0",
|
||||||
"tailwindcss": "^3.4.13",
|
"tailwindcss": "^4.2.2",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^6.0.2",
|
||||||
"vite": "^7.0.0",
|
"vite": "^8.0.8",
|
||||||
"vite-plugin-checker": "^0.12.0",
|
"vite-plugin-checker": "^0.12.0",
|
||||||
"vue": "^3.5.0",
|
"vue": "^3.5.0",
|
||||||
"vue-tsc": "^3.0.0"
|
"vue-tsc": "^3.0.0"
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
"@tailwindcss/container-queries": "^0.1.1",
|
"@tailwindcss/container-queries": "^0.1.1",
|
||||||
"@tanstack/vue-form": "^1.3.1",
|
"@tanstack/vue-form": "^1.3.1",
|
||||||
"@tanstack/vue-query": "^5.56.2",
|
"@tanstack/vue-query": "^5.56.2",
|
||||||
"@tanstack/vue-query-devtools": "^5.58.0",
|
"@tanstack/vue-query-devtools": "^6.1.16",
|
||||||
"@tanstack/vue-table": "^8.21.2",
|
"@tanstack/vue-table": "^8.21.2",
|
||||||
"@vue/eslint-config-prettier": "^10.2.0",
|
"@vue/eslint-config-prettier": "^10.2.0",
|
||||||
"@vue/eslint-config-typescript": "^14.3.0",
|
"@vue/eslint-config-typescript": "^14.3.0",
|
||||||
@@ -64,12 +64,12 @@
|
|||||||
"dayjs": "^1.11.11",
|
"dayjs": "^1.11.11",
|
||||||
"echarts": "^6.0.0",
|
"echarts": "^6.0.0",
|
||||||
"focus-trap": "^8.0.0",
|
"focus-trap": "^8.0.0",
|
||||||
"lucide-vue-next": "^0.487.0",
|
"lucide-vue-next": "^1.0.0",
|
||||||
"parse-duration": "^2.0.1",
|
"parse-duration": "^2.0.1",
|
||||||
"pinia": "^3.0.0",
|
"pinia": "^3.0.0",
|
||||||
"radix-vue": "^1.9.6",
|
"radix-vue": "^1.9.6",
|
||||||
"reka-ui": "^2.8.2",
|
"reka-ui": "^2.8.2",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"vue-echarts": "^8.0.0",
|
"vue-echarts": "^8.0.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@solidtime/ui",
|
"name": "@solidtime/ui",
|
||||||
"version": "0.0.17",
|
"version": "0.0.20",
|
||||||
"description": "Package containing the solidtime ui components",
|
"description": "Package containing the solidtime ui components",
|
||||||
"main": "./dist/solidtime-ui-lib.umd.cjs",
|
"main": "./dist/solidtime-ui-lib.umd.cjs",
|
||||||
"module": "./dist/solidtime-ui-lib.js",
|
"module": "./dist/solidtime-ui-lib.js",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '..';
|
|||||||
import type { DayEvent, ActivityBox } from './calendarTypes';
|
import type { DayEvent, ActivityBox } from './calendarTypes';
|
||||||
import type { WindowActivityInPeriod } from './activityTypes';
|
import type { WindowActivityInPeriod } from './activityTypes';
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
dayStr: string;
|
dayStr: string;
|
||||||
totalGridHeight: number;
|
totalGridHeight: number;
|
||||||
hasActivityStatus: boolean;
|
hasActivityStatus: boolean;
|
||||||
@@ -34,6 +34,8 @@ defineProps<{
|
|||||||
getActivityBoxActivities: (box: ActivityBox) => WindowActivityInPeriod[];
|
getActivityBoxActivities: (box: ActivityBox) => WindowActivityInPeriod[];
|
||||||
getActivityPercentage: (count: number, total: number) => string;
|
getActivityPercentage: (count: number, total: number) => string;
|
||||||
getActivityText: (activity: WindowActivityInPeriod) => string;
|
getActivityText: (activity: WindowActivityInPeriod) => string;
|
||||||
|
getTopActivity: (box: ActivityBox) => WindowActivityInPeriod | null;
|
||||||
|
isDayView: boolean;
|
||||||
|
|
||||||
// Selection
|
// Selection
|
||||||
showSelection: boolean;
|
showSelection: boolean;
|
||||||
@@ -46,6 +48,16 @@ defineProps<{
|
|||||||
selectionEndHeight: number;
|
selectionEndHeight: number;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
function isUncoveredByEvents(abox: ActivityBox): boolean {
|
||||||
|
return !props.dayEvents.some((de) => {
|
||||||
|
const eTop = de.top;
|
||||||
|
const eBottom = de.top + de.height;
|
||||||
|
const aTop = abox.top;
|
||||||
|
const aBottom = abox.top + abox.height;
|
||||||
|
return eTop < aBottom && eBottom > aTop;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'event-pointerdown', event: PointerEvent, dayEvent: DayEvent): void;
|
(e: 'event-pointerdown', event: PointerEvent, dayEvent: DayEvent): void;
|
||||||
(e: 'event-keydown-enter', dayEvent: DayEvent): void;
|
(e: 'event-keydown-enter', dayEvent: DayEvent): void;
|
||||||
@@ -55,6 +67,7 @@ const emit = defineEmits<{
|
|||||||
dayEvent: DayEvent,
|
dayEvent: DayEvent,
|
||||||
edge: 'start' | 'end'
|
edge: 'start' | 'end'
|
||||||
): void;
|
): void;
|
||||||
|
(e: 'activity-pointerdown', event: PointerEvent): void;
|
||||||
}>();
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -63,16 +76,20 @@ const emit = defineEmits<{
|
|||||||
class="fc-timegrid-col relative border-r border-border bg-transparent pointer-events-none"
|
class="fc-timegrid-col relative border-r border-border bg-transparent pointer-events-none"
|
||||||
:class="{
|
:class="{
|
||||||
'has-activity-status': hasActivityStatus,
|
'has-activity-status': hasActivityStatus,
|
||||||
|
'activity-expanded': hasActivityStatus && isDayView,
|
||||||
}"
|
}"
|
||||||
:data-date="dayStr"
|
:data-date="dayStr"
|
||||||
:style="{ height: totalGridHeight + 'px' }">
|
:style="{ height: totalGridHeight + 'px' }">
|
||||||
<div
|
<div
|
||||||
class="absolute inset-y-0 left-0.5 right-0.5"
|
class="absolute inset-y-0 left-0.5 right-0.5"
|
||||||
:class="{ 'fc-events-inset': hasActivityStatus }">
|
:class="{
|
||||||
|
'fc-events-inset': hasActivityStatus && !isDayView,
|
||||||
|
'fc-events-inset-expanded': hasActivityStatus && isDayView,
|
||||||
|
}">
|
||||||
<div
|
<div
|
||||||
v-for="dayEvent in dayEvents"
|
v-for="dayEvent in dayEvents"
|
||||||
:key="dayEvent.event.id"
|
:key="dayEvent.event.id"
|
||||||
class="fc-event group pointer-events-auto rounded-sm text-xs cursor-pointer shadow-card overflow-hidden border border-border touch-none select-none hover:shadow-dropdown focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
|
class="fc-event group pointer-events-auto rounded-sm text-xs cursor-pointer shadow-card border border-border touch-none select-none hover:shadow-dropdown focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
|
||||||
:class="[
|
:class="[
|
||||||
getEventOpacityClass(dayEvent, dayStr),
|
getEventOpacityClass(dayEvent, dayStr),
|
||||||
{
|
{
|
||||||
@@ -91,9 +108,7 @@ const emit = defineEmits<{
|
|||||||
:aria-label="dayEvent.event.title"
|
:aria-label="dayEvent.event.title"
|
||||||
role="button"
|
role="button"
|
||||||
@pointerdown="emit('event-pointerdown', $event, dayEvent)"
|
@pointerdown="emit('event-pointerdown', $event, dayEvent)"
|
||||||
@keydown.enter.prevent="
|
@keydown.enter.prevent="emit('event-keydown-enter', dayEvent)">
|
||||||
!dayEvent.event.isRunning && emit('event-keydown-enter', dayEvent)
|
|
||||||
">
|
|
||||||
<div
|
<div
|
||||||
v-if="!dayEvent.isClippedStart"
|
v-if="!dayEvent.isClippedStart"
|
||||||
class="fc-event-resizer fc-event-resizer-start absolute z-[99] w-full h-3 left-0 top-[-2px] cursor-row-resize flex items-center justify-center opacity-0 group-hover:opacity-100"
|
class="fc-event-resizer fc-event-resizer-start absolute z-[99] w-full h-3 left-0 top-[-2px] cursor-row-resize flex items-center justify-center opacity-0 group-hover:opacity-100"
|
||||||
@@ -122,15 +137,47 @@ const emit = defineEmits<{
|
|||||||
class="fc-timegrid-now-indicator-line absolute left-0 right-0 border-t-2 border-red-500 z-50 pointer-events-none"
|
class="fc-timegrid-now-indicator-line absolute left-0 right-0 border-t-2 border-red-500 z-50 pointer-events-none"
|
||||||
:style="{ top: nowIndicatorTop + 'px' }"></div>
|
:style="{ top: nowIndicatorTop + 'px' }"></div>
|
||||||
|
|
||||||
<TooltipProvider :delay-duration="0">
|
<TooltipProvider :disable-hoverable-content="true" :delay-duration="0">
|
||||||
<Tooltip v-for="(abox, ai) in activityBoxes" :key="'activity-' + ai">
|
<Tooltip v-for="(abox, ai) in activityBoxes" :key="'activity-' + ai">
|
||||||
<TooltipTrigger as-child>
|
<TooltipTrigger as-child>
|
||||||
<div
|
<div
|
||||||
class="activity-status-box"
|
class="activity-status-box"
|
||||||
:class="abox.isIdle ? 'idle' : 'active'"
|
:class="[
|
||||||
:style="{ top: abox.top + 'px', height: abox.height + 'px' }"></div>
|
abox.isIdle ? 'idle' : 'active',
|
||||||
|
{
|
||||||
|
'activity-status-box-expanded': isDayView,
|
||||||
|
'activity-status-box-uncovered':
|
||||||
|
!isDayView &&
|
||||||
|
!abox.isIdle &&
|
||||||
|
getTopActivity(abox) &&
|
||||||
|
isUncoveredByEvents(abox),
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
:style="{ top: abox.top + 'px', height: abox.height + 'px' }"
|
||||||
|
@pointerdown="emit('activity-pointerdown', $event)">
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
!abox.isIdle &&
|
||||||
|
getTopActivity(abox) &&
|
||||||
|
abox.height >= 16 &&
|
||||||
|
(isDayView || isUncoveredByEvents(abox))
|
||||||
|
"
|
||||||
|
class="activity-status-content">
|
||||||
|
<img
|
||||||
|
v-if="getTopActivity(abox)?.icon"
|
||||||
|
:src="getTopActivity(abox)!.icon!"
|
||||||
|
:alt="getTopActivity(abox)!.appName"
|
||||||
|
class="activity-status-icon" />
|
||||||
|
<div v-else class="activity-status-icon-fallback">
|
||||||
|
{{ getTopActivity(abox)!.appName.charAt(0).toUpperCase() }}
|
||||||
|
</div>
|
||||||
|
<span class="activity-status-label">
|
||||||
|
{{ getTopActivity(abox)!.label || getTopActivity(abox)!.appName }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right" :side-offset="8">
|
<TooltipContent :side="isDayView ? 'right' : 'left'" :side-offset="8">
|
||||||
<template v-if="getActivityBoxActivities(abox).length === 0">
|
<template v-if="getActivityBoxActivities(abox).length === 0">
|
||||||
{{ getActivityBoxLabel(abox) }}
|
{{ getActivityBoxLabel(abox) }}
|
||||||
</template>
|
</template>
|
||||||
@@ -271,13 +318,99 @@ const emit = defineEmits<{
|
|||||||
background-color: rgba(156, 163, 175, 0.5);
|
background-color: rgba(156, 163, 175, 0.5);
|
||||||
}
|
}
|
||||||
.activity-status-box.active::before {
|
.activity-status-box.active::before {
|
||||||
background-color: rgba(34, 197, 94, 0.3);
|
background-color: rgba(14, 165, 233, 0.3);
|
||||||
}
|
}
|
||||||
.activity-status-box.active:hover::before {
|
.activity-status-box.active:hover::before {
|
||||||
background-color: rgba(34, 197, 94, 1);
|
background-color: rgba(14, 165, 233, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Uncovered activity boxes in week view — fill column width */
|
||||||
|
.activity-status-box-uncovered {
|
||||||
|
width: calc(100% - 4px);
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.activity-status-box-uncovered::before {
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
.activity-status-box-uncovered.active::before {
|
||||||
|
background-color: rgba(14, 165, 233, 0.12);
|
||||||
|
}
|
||||||
|
.activity-status-box-uncovered.active:hover::before {
|
||||||
|
background-color: rgba(14, 165, 233, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Expanded activity boxes for day view */
|
||||||
|
.activity-status-box-expanded {
|
||||||
|
width: 200px;
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.activity-status-box-expanded::before {
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
.activity-status-box-expanded.idle::before {
|
||||||
|
background-color: rgba(156, 163, 175, 0.08);
|
||||||
|
}
|
||||||
|
.activity-status-box-expanded.idle:hover::before {
|
||||||
|
background-color: rgba(156, 163, 175, 0.2);
|
||||||
|
}
|
||||||
|
.activity-status-box-expanded.active::before {
|
||||||
|
background-color: rgba(14, 165, 233, 0.12);
|
||||||
|
}
|
||||||
|
.activity-status-box-expanded.active:hover::before {
|
||||||
|
background-color: rgba(14, 165, 233, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-status-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-status-icon {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-status-icon-fallback {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background-color: rgba(14, 165, 233, 0.2);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: rgba(14, 165, 233, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-status-label {
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.2;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fc-events-inset {
|
.fc-events-inset {
|
||||||
left: 8px;
|
left: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fc-events-inset-expanded {
|
||||||
|
left: 204px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ const {
|
|||||||
getActivityBoxActivities,
|
getActivityBoxActivities,
|
||||||
getActivityPercentage,
|
getActivityPercentage,
|
||||||
getActivityText,
|
getActivityText,
|
||||||
|
getTopActivity,
|
||||||
} = useActivityBoxes({
|
} = useActivityBoxes({
|
||||||
activityPeriods: () => props.activityPeriods,
|
activityPeriods: () => props.activityPeriods,
|
||||||
viewDays,
|
viewDays,
|
||||||
@@ -280,6 +281,22 @@ watch(showEditTimeEntryModal, (value) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guards slot pointer-down so that clicks which dismiss an open Reka UI
|
||||||
|
* layer (context menu, popover, dialog) don't simultaneously start a
|
||||||
|
* new time-entry selection on the calendar grid.
|
||||||
|
*
|
||||||
|
* Because Reka's DismissableLayer registers its document-level
|
||||||
|
* `pointerdown` listener *without* capture, it fires AFTER the
|
||||||
|
* calendar grid's own handler. That means when this guard runs,
|
||||||
|
* `contextMenuOpen` (and modal refs) still reflect the *open* state.
|
||||||
|
*/
|
||||||
|
function guardedSlotPointerDown(e: PointerEvent) {
|
||||||
|
if (contextMenuOpen.value) return;
|
||||||
|
if (showCreateTimeEntryModal.value || showEditTimeEntryModal.value) return;
|
||||||
|
onSlotPointerDown(e);
|
||||||
|
}
|
||||||
|
|
||||||
const scrollToCurrentTime = () => {
|
const scrollToCurrentTime = () => {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
if (!scrollerRef.value) return;
|
if (!scrollerRef.value) return;
|
||||||
@@ -314,6 +331,18 @@ watch(
|
|||||||
{ deep: true }
|
{ deep: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let hasScrolledOnLoad = false;
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.loading,
|
||||||
|
(loading) => {
|
||||||
|
if (!loading && !hasScrolledOnLoad) {
|
||||||
|
hasScrolledOnLoad = true;
|
||||||
|
scrollToCurrentTime();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
scrollToCurrentTime();
|
scrollToCurrentTime();
|
||||||
emitDatesChange();
|
emitDatesChange();
|
||||||
@@ -478,7 +507,7 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
|
|||||||
<div
|
<div
|
||||||
v-for="day in viewDays"
|
v-for="day in viewDays"
|
||||||
:key="day.format('YYYY-MM-DD')"
|
:key="day.format('YYYY-MM-DD')"
|
||||||
class="fc-col-header-cell border-r border-b border-border px-2 py-3 bg-default-background text-center"
|
class="fc-col-header-cell border-r border-border px-2 py-3 bg-default-background text-center"
|
||||||
:class="{
|
:class="{
|
||||||
'bg-secondary': isToday(day),
|
'bg-secondary': isToday(day),
|
||||||
'fc-day-today': isToday(day),
|
'fc-day-today': isToday(day),
|
||||||
@@ -514,7 +543,7 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
|
|||||||
:style="{ height: SLOT_HEIGHT + 'px' }">
|
:style="{ height: SLOT_HEIGHT + 'px' }">
|
||||||
<span
|
<span
|
||||||
v-if="slot.isHour"
|
v-if="slot.isHour"
|
||||||
class="fc-timegrid-slot-label-cushion text-[0.8125rem] text-muted-foreground leading-none block">
|
class="fc-timegrid-slot-label-cushion text-[0.8125rem] text-muted-foreground leading-none block font-light">
|
||||||
{{ formatSlotLabel(slot.minutes / 60) }}
|
{{ formatSlotLabel(slot.minutes / 60) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -522,14 +551,32 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
class="flex-1 min-w-0 relative"
|
class="flex-1 min-w-0 relative"
|
||||||
@pointerdown="onSlotPointerDown($event)">
|
@pointerdown="guardedSlotPointerDown($event)">
|
||||||
<div
|
<div
|
||||||
class="bg-background"
|
class="bg-background relative"
|
||||||
:style="{ height: totalGridHeight + 'px' }">
|
:style="{ height: totalGridHeight + 'px' }">
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 grid"
|
||||||
|
:style="{
|
||||||
|
gridTemplateColumns:
|
||||||
|
'repeat(' + viewDays.length + ', 1fr)',
|
||||||
|
}">
|
||||||
|
<div
|
||||||
|
v-for="day in viewDays"
|
||||||
|
:key="'bg-' + day.format('YYYY-MM-DD')"
|
||||||
|
:style="
|
||||||
|
isToday(day)
|
||||||
|
? {
|
||||||
|
backgroundColor:
|
||||||
|
'var(--theme-color-default-background)',
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
" />
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
v-for="slot in slots"
|
v-for="slot in slots"
|
||||||
:key="'lane-' + slot.time"
|
:key="'lane-' + slot.time"
|
||||||
class="fc-timegrid-slot fc-timegrid-slot-lane border-t border-border box-border"
|
class="fc-timegrid-slot fc-timegrid-slot-lane border-t border-border box-border relative"
|
||||||
:class="{
|
:class="{
|
||||||
'fc-timegrid-slot-minor border-dotted':
|
'fc-timegrid-slot-minor border-dotted':
|
||||||
!slot.isHour,
|
!slot.isHour,
|
||||||
@@ -581,6 +628,8 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
|
|||||||
:get-activity-box-activities="getActivityBoxActivities"
|
:get-activity-box-activities="getActivityBoxActivities"
|
||||||
:get-activity-percentage="getActivityPercentage"
|
:get-activity-percentage="getActivityPercentage"
|
||||||
:get-activity-text="getActivityText"
|
:get-activity-text="getActivityText"
|
||||||
|
:get-top-activity="getTopActivity"
|
||||||
|
:is-day-view="activeView === 'timeGridDay'"
|
||||||
:show-selection="
|
:show-selection="
|
||||||
isSelecting || showCreateTimeEntryModal
|
isSelecting || showCreateTimeEntryModal
|
||||||
"
|
"
|
||||||
@@ -599,6 +648,7 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
|
|||||||
:selection-height="selectionHeight"
|
:selection-height="selectionHeight"
|
||||||
:selection-end-top="selectionEndTop"
|
:selection-end-top="selectionEndTop"
|
||||||
:selection-end-height="selectionEndHeight"
|
:selection-end-height="selectionEndHeight"
|
||||||
|
@activity-pointerdown="guardedSlotPointerDown"
|
||||||
@event-pointerdown="
|
@event-pointerdown="
|
||||||
(e, dayEvent) =>
|
(e, dayEvent) =>
|
||||||
onEventPointerDown(e, dayEvent.event, dayEvent)
|
onEventPointerDown(e, dayEvent.event, dayEvent)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export interface WindowActivityInPeriod {
|
export interface WindowActivityInPeriod {
|
||||||
appName: string;
|
appName: string;
|
||||||
url: string | null;
|
label: string | null;
|
||||||
count: number;
|
count: number;
|
||||||
icon?: string | null;
|
icon?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,19 +12,13 @@ export function useActivityBoxes(params: {
|
|||||||
calendarSettings: Ref<CalendarSettings>;
|
calendarSettings: Ref<CalendarSettings>;
|
||||||
minutesToPixels: (minutes: number) => number;
|
minutesToPixels: (minutes: number) => number;
|
||||||
}) {
|
}) {
|
||||||
function formatActivityDuration(durationMinutes: number): string {
|
|
||||||
const hours = Math.floor(durationMinutes / 60);
|
|
||||||
const minutes = durationMinutes % 60;
|
|
||||||
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getActivityBoxLabel(box: ActivityBox): string {
|
function getActivityBoxLabel(box: ActivityBox): string {
|
||||||
const periodStart = getLocalizedDayJs(box.period.start);
|
const periodStart = getLocalizedDayJs(box.period.start);
|
||||||
const periodEnd = getLocalizedDayJs(box.period.end);
|
const periodEnd = getLocalizedDayJs(box.period.end);
|
||||||
const durationMinutes = Math.round(periodEnd.diff(periodStart, 'minute', true));
|
const startText = periodStart.format('HH:mm');
|
||||||
const durationText = formatActivityDuration(durationMinutes);
|
const endText = periodEnd.format('HH:mm');
|
||||||
const status = box.isIdle ? 'Idling' : 'Active';
|
const status = box.isIdle ? 'Idling' : 'Active';
|
||||||
return `${status} (${durationText})`;
|
return `${status} (${startText} - ${endText})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getActivityBoxActivities(box: ActivityBox) {
|
function getActivityBoxActivities(box: ActivityBox) {
|
||||||
@@ -37,7 +31,16 @@ export function useActivityBoxes(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getActivityText(activity: WindowActivityInPeriod): string {
|
function getActivityText(activity: WindowActivityInPeriod): string {
|
||||||
return activity.url ? `${activity.appName} - ${activity.url}` : activity.appName;
|
return activity.label ? `${activity.appName} - ${activity.label}` : activity.appName;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTopActivity(box: ActivityBox): WindowActivityInPeriod | null {
|
||||||
|
const activities = box.period.windowActivities;
|
||||||
|
if (!activities || activities.length === 0) return null;
|
||||||
|
return activities.reduce<WindowActivityInPeriod>(
|
||||||
|
(top, a) => (a.count > top.count ? a : top),
|
||||||
|
activities[0]!
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const activityBoxes = computed<ActivityBox[]>(() => {
|
const activityBoxes = computed<ActivityBox[]>(() => {
|
||||||
@@ -99,5 +102,6 @@ export function useActivityBoxes(params: {
|
|||||||
getActivityBoxActivities,
|
getActivityBoxActivities,
|
||||||
getActivityPercentage,
|
getActivityPercentage,
|
||||||
getActivityText,
|
getActivityText,
|
||||||
|
getTopActivity,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { ref, type Ref, type ComputedRef } from 'vue';
|
import { ref, type Ref, type ComputedRef } from 'vue';
|
||||||
import type { Dayjs } from 'dayjs';
|
import type { Dayjs } from 'dayjs';
|
||||||
import type { TimeEntry } from '@/packages/api/src';
|
import type { TimeEntry } from '@/packages/api/src';
|
||||||
import { getDayJsInstance } from '../utils/time';
|
import { getDayJsInstance, getLocalizedDayJsFromMinutes } from '../utils/time';
|
||||||
import { getUserTimezone } from '../utils/settings';
|
|
||||||
import type { CalendarSettings } from './calendarSettings';
|
import type { CalendarSettings } from './calendarSettings';
|
||||||
import type { CalendarEvent } from './calendarTypes';
|
import type { CalendarEvent } from './calendarTypes';
|
||||||
|
|
||||||
@@ -34,11 +34,8 @@ export function useContextMenu(params: {
|
|||||||
const snap = params.calendarSettings.value.snapMinutes;
|
const snap = params.calendarSettings.value.snapMinutes;
|
||||||
const snappedMinutes = Math.floor(minutesFromGridStart / snap) * snap;
|
const snappedMinutes = Math.floor(minutesFromGridStart / snap) * snap;
|
||||||
|
|
||||||
const dayjs = getDayJsInstance();
|
const startLocal = getLocalizedDayJsFromMinutes(date, snappedMinutes);
|
||||||
const startLocal = dayjs(`${date}T00:00:00`)
|
const snappedEnd = getLocalizedDayJsFromMinutes(date, snappedMinutes + snap);
|
||||||
.tz(getUserTimezone(), true)
|
|
||||||
.add(snappedMinutes, 'minute');
|
|
||||||
const snappedEnd = startLocal.add(snap, 'minute');
|
|
||||||
|
|
||||||
return { start: startLocal.utc(), end: snappedEnd.utc() };
|
return { start: startLocal.utc(), end: snappedEnd.utc() };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { computed, ref, onUnmounted, type Ref, type ComputedRef } from 'vue';
|
import { computed, ref, onUnmounted, type Ref, type ComputedRef } from 'vue';
|
||||||
import type { Dayjs } from 'dayjs';
|
import type { Dayjs } from 'dayjs';
|
||||||
import type { TimeEntry } from '@/packages/api/src';
|
import type { TimeEntry } from '@/packages/api/src';
|
||||||
import { getDayJsInstance, getLocalizedDayJs } from '../utils/time';
|
import { getLocalizedDayJs, getLocalizedDayJsFromMinutes } from '../utils/time';
|
||||||
import { getUserTimezone } from '../utils/settings';
|
|
||||||
import type { CalendarSettings } from './calendarSettings';
|
import type { CalendarSettings } from './calendarSettings';
|
||||||
import type { CalendarEvent, DayEvent } from './calendarTypes';
|
import type { CalendarEvent, DayEvent } from './calendarTypes';
|
||||||
import { SLOT_HEIGHT, DRAG_THRESHOLD } from './calendarTypes';
|
import { SLOT_HEIGHT, DRAG_THRESHOLD } from './calendarTypes';
|
||||||
@@ -67,8 +66,7 @@ export function useEventDrag(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (dayEvent.isClippedStart && originDay && ev.timeEntry.end) {
|
if (dayEvent.isClippedStart && originDay && ev.timeEntry.end) {
|
||||||
const dayjs = getDayJsInstance();
|
const dayMidnight = getLocalizedDayJsFromMinutes(originDay, 0);
|
||||||
const dayMidnight = dayjs(`${originDay}T00:00:00`).tz(getUserTimezone(), true);
|
|
||||||
const evStart = getLocalizedDayJs(ev.timeEntry.start);
|
const evStart = getLocalizedDayJs(ev.timeEntry.start);
|
||||||
const eventStartFromGridStart = evStart.diff(dayMidnight, 'minute') - s.startHour * 60;
|
const eventStartFromGridStart = evStart.diff(dayMidnight, 'minute') - s.startHour * 60;
|
||||||
const segmentTopMinutes = (dayEvent.top / SLOT_HEIGHT) * s.slotMinutes;
|
const segmentTopMinutes = (dayEvent.top / SLOT_HEIGHT) * s.slotMinutes;
|
||||||
@@ -154,13 +152,11 @@ export function useEventDrag(params: {
|
|||||||
const lowerBound = startMin - 4 * 60;
|
const lowerBound = startMin - 4 * 60;
|
||||||
const clampedMinutes = Math.max(lowerBound, Math.min(snappedMinutes, s.endHour * 60));
|
const clampedMinutes = Math.max(lowerBound, Math.min(snappedMinutes, s.endHour * 60));
|
||||||
|
|
||||||
const dayjs = getDayJsInstance();
|
const originalSegmentStart = getLocalizedDayJsFromMinutes(
|
||||||
const originalSegmentStart = dayjs(`${savedOriginalDayStr}T00:00:00`)
|
savedOriginalDayStr,
|
||||||
.tz(getUserTimezone(), true)
|
startMin + params.pixelsToMinutesFromMidnight(dragStartEventTop)
|
||||||
.add(startMin + params.pixelsToMinutesFromMidnight(dragStartEventTop), 'minute');
|
);
|
||||||
const newSegmentStart = dayjs(`${targetDateStr}T00:00:00`)
|
const newSegmentStart = getLocalizedDayJsFromMinutes(targetDateStr, clampedMinutes);
|
||||||
.tz(getUserTimezone(), true)
|
|
||||||
.add(clampedMinutes, 'minute');
|
|
||||||
const deltaMs = newSegmentStart.diff(originalSegmentStart);
|
const deltaMs = newSegmentStart.diff(originalSegmentStart);
|
||||||
|
|
||||||
const origStart = getLocalizedDayJs(timeEntry.start);
|
const origStart = getLocalizedDayJs(timeEntry.start);
|
||||||
@@ -240,11 +236,14 @@ export function useEventDrag(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Multi-day: compute actual start/end datetimes, then clip per day
|
// Multi-day: compute actual start/end datetimes, then clip per day
|
||||||
const dayjs = getDayJsInstance();
|
const eventStartAbsolute = getLocalizedDayJsFromMinutes(
|
||||||
const eventStartAbsolute = dayjs(`${dragCurrentDay.value}T00:00:00`)
|
dragCurrentDay.value,
|
||||||
.tz(getUserTimezone(), true)
|
startMin + eventStartOnGrid
|
||||||
.add(startMin + eventStartOnGrid, 'minute');
|
);
|
||||||
const eventEndAbsolute = eventStartAbsolute.add(dragFullDurationMinutes, 'minute');
|
const eventEndAbsolute = getLocalizedDayJsFromMinutes(
|
||||||
|
dragCurrentDay.value,
|
||||||
|
startMin + eventStartOnGrid + dragFullDurationMinutes
|
||||||
|
);
|
||||||
|
|
||||||
const result: Record<string, Record<string, string>> = {};
|
const result: Record<string, Record<string, string>> = {};
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { computed, ref, onUnmounted, type Ref, type ComputedRef } from 'vue';
|
import { computed, ref, onUnmounted, type Ref, type ComputedRef } from 'vue';
|
||||||
import type { Dayjs } from 'dayjs';
|
import type { Dayjs } from 'dayjs';
|
||||||
import type { TimeEntry } from '@/packages/api/src';
|
import type { TimeEntry } from '@/packages/api/src';
|
||||||
import { getDayJsInstance, getLocalizedDayJs } from '../utils/time';
|
import { getDayJsInstance, getLocalizedDayJs, getLocalizedDayJsFromMinutes } from '../utils/time';
|
||||||
import { getUserTimezone } from '../utils/settings';
|
|
||||||
import type { CalendarSettings } from './calendarSettings';
|
import type { CalendarSettings } from './calendarSettings';
|
||||||
import type { CalendarEvent, DayEvent } from './calendarTypes';
|
import type { CalendarEvent, DayEvent } from './calendarTypes';
|
||||||
import { SLOT_HEIGHT } from './calendarTypes';
|
import { SLOT_HEIGHT } from './calendarTypes';
|
||||||
@@ -11,10 +10,6 @@ function snapTo(value: number, step: number): number {
|
|||||||
return Math.round(value / step) * step;
|
return Math.round(value / step) * step;
|
||||||
}
|
}
|
||||||
|
|
||||||
function dayMidnightLocal(dayStr: string): Dayjs {
|
|
||||||
return getDayJsInstance()(`${dayStr}T00:00:00`).tz(getUserTimezone(), true);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useEventResize(params: {
|
export function useEventResize(params: {
|
||||||
calendarSettings: Ref<CalendarSettings>;
|
calendarSettings: Ref<CalendarSettings>;
|
||||||
viewDays: ComputedRef<Dayjs[]>;
|
viewDays: ComputedRef<Dayjs[]>;
|
||||||
@@ -89,7 +84,7 @@ export function useEventResize(params: {
|
|||||||
),
|
),
|
||||||
s.snapMinutes
|
s.snapMinutes
|
||||||
);
|
);
|
||||||
return { start, end: dayMidnightLocal(endDay).add(endMinutes, 'minute') };
|
return { start, end: getLocalizedDayJsFromMinutes(endDay, endMinutes) };
|
||||||
} else {
|
} else {
|
||||||
const end = resizeOriginalEvent.isRunning
|
const end = resizeOriginalEvent.isRunning
|
||||||
? getLocalizedDayJs()
|
? getLocalizedDayJs()
|
||||||
@@ -105,7 +100,7 @@ export function useEventResize(params: {
|
|||||||
params.pixelsToMinutesFromMidnight(resizeCurrentTop.value),
|
params.pixelsToMinutesFromMidnight(resizeCurrentTop.value),
|
||||||
s.snapMinutes
|
s.snapMinutes
|
||||||
);
|
);
|
||||||
return { start: dayMidnightLocal(startDay).add(startMinutes, 'minute'), end };
|
return { start: getLocalizedDayJsFromMinutes(startDay, startMinutes), end };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { computed, ref, onUnmounted, type Ref, type ComputedRef } from 'vue';
|
import { computed, ref, onUnmounted, type Ref, type ComputedRef } from 'vue';
|
||||||
import type { Dayjs } from 'dayjs';
|
import type { Dayjs } from 'dayjs';
|
||||||
import { getDayJsInstance } from '../utils/time';
|
import { getLocalizedDayJsFromMinutes } from '../utils/time';
|
||||||
import { getUserTimezone } from '../utils/settings';
|
|
||||||
import type { CalendarSettings } from './calendarSettings';
|
import type { CalendarSettings } from './calendarSettings';
|
||||||
import { SLOT_HEIGHT } from './calendarTypes';
|
import { SLOT_HEIGHT } from './calendarTypes';
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ export function useSlotSelection(params: {
|
|||||||
function onSlotPointerDown(e: PointerEvent) {
|
function onSlotPointerDown(e: PointerEvent) {
|
||||||
if (e.button !== 0) return;
|
if (e.button !== 0) return;
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
if (target.closest('.fc-event') || target.closest('.activity-status-box')) return;
|
if (target.closest('.fc-event')) return;
|
||||||
|
|
||||||
const dateStr = params.getDayFromClientX(e.clientX);
|
const dateStr = params.getDayFromClientX(e.clientX);
|
||||||
if (!dateStr) return;
|
if (!dateStr) return;
|
||||||
@@ -102,8 +102,6 @@ export function useSlotSelection(params: {
|
|||||||
|
|
||||||
const s = params.calendarSettings.value;
|
const s = params.calendarSettings.value;
|
||||||
const snap = s.snapMinutes;
|
const snap = s.snapMinutes;
|
||||||
const dayjs = getDayJsInstance();
|
|
||||||
|
|
||||||
const startMinutes = params.pixelsToMinutesFromMidnight(selectionTop.value);
|
const startMinutes = params.pixelsToMinutesFromMidnight(selectionTop.value);
|
||||||
const snappedStartMin = Math.floor(startMinutes / snap) * snap;
|
const snappedStartMin = Math.floor(startMinutes / snap) * snap;
|
||||||
|
|
||||||
@@ -138,12 +136,8 @@ export function useSlotSelection(params: {
|
|||||||
if (endMin <= 0) endMin = snap;
|
if (endMin <= 0) endMin = snap;
|
||||||
}
|
}
|
||||||
|
|
||||||
startLocal = dayjs(`${startDateStr}T00:00:00`)
|
startLocal = getLocalizedDayJsFromMinutes(startDateStr, startMin);
|
||||||
.tz(getUserTimezone(), true)
|
endLocal = getLocalizedDayJsFromMinutes(endDateStr, endMin);
|
||||||
.add(startMin, 'minute');
|
|
||||||
endLocal = dayjs(`${endDateStr}T00:00:00`)
|
|
||||||
.tz(getUserTimezone(), true)
|
|
||||||
.add(endMin, 'minute');
|
|
||||||
} else {
|
} else {
|
||||||
const startDateStr = selectionStartDay;
|
const startDateStr = selectionStartDay;
|
||||||
const endMinutes = params.pixelsToMinutesFromMidnight(
|
const endMinutes = params.pixelsToMinutesFromMidnight(
|
||||||
@@ -153,12 +147,8 @@ export function useSlotSelection(params: {
|
|||||||
if (snappedEndMin <= snappedStartMin) {
|
if (snappedEndMin <= snappedStartMin) {
|
||||||
snappedEndMin = snappedStartMin + snap;
|
snappedEndMin = snappedStartMin + snap;
|
||||||
}
|
}
|
||||||
startLocal = dayjs(`${startDateStr}T00:00:00`)
|
startLocal = getLocalizedDayJsFromMinutes(startDateStr, snappedStartMin);
|
||||||
.tz(getUserTimezone(), true)
|
endLocal = getLocalizedDayJsFromMinutes(startDateStr, snappedEndMin);
|
||||||
.add(snappedStartMin, 'minute');
|
|
||||||
endLocal = dayjs(`${startDateStr}T00:00:00`)
|
|
||||||
.tz(getUserTimezone(), true)
|
|
||||||
.add(snappedEndMin, 'minute');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
params.onSelectionComplete(startLocal.utc(), endLocal.utc());
|
params.onSelectionComplete(startLocal.utc(), endLocal.utc());
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const props = defineProps<{
|
|||||||
currency: string;
|
currency: string;
|
||||||
enableEstimatedTime: boolean;
|
enableEstimatedTime: boolean;
|
||||||
organizationBillableRate: number | null;
|
organizationBillableRate: number | null;
|
||||||
|
initialProjectName?: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const activeClients = computed(() => {
|
const activeClients = computed(() => {
|
||||||
@@ -34,7 +35,7 @@ const activeClients = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const project = ref<CreateProjectBody>({
|
const project = ref<CreateProjectBody>({
|
||||||
name: '',
|
name: props.initialProjectName ?? '',
|
||||||
color: getRandomColor(),
|
color: getRandomColor(),
|
||||||
client_id: null,
|
client_id: null,
|
||||||
billable_rate: null,
|
billable_rate: null,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import type {
|
|||||||
} from '@/packages/api/src';
|
} from '@/packages/api/src';
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
import { Checkbox } from '@/packages/ui/src';
|
import { Button, Checkbox } from '@/packages/ui/src';
|
||||||
import { FieldLabel } from '../field';
|
import { FieldLabel } from '../field';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -66,7 +66,7 @@ const showMassUpdateModal = ref(false);
|
|||||||
:class="
|
:class="
|
||||||
twMerge(
|
twMerge(
|
||||||
props.class,
|
props.class,
|
||||||
'text-sm py-1.5 font-medium hidden sm:flex border-b border-border-primary items-center space-x-3'
|
'text-sm h-8 font-medium hidden sm:flex border-b border-border-primary items-center space-x-3'
|
||||||
)
|
)
|
||||||
">
|
">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -83,20 +83,24 @@ const showMassUpdateModal = ref(false);
|
|||||||
<FieldLabel v-else for="selectAll" class="text-text-secondary select-none"
|
<FieldLabel v-else for="selectAll" class="text-text-secondary select-none"
|
||||||
>Select All</FieldLabel
|
>Select All</FieldLabel
|
||||||
>
|
>
|
||||||
<button
|
<Button
|
||||||
v-if="selectedTimeEntries.length"
|
v-if="selectedTimeEntries.length"
|
||||||
class="text-text-tertiary flex space-x-1 items-center hover:text-text-secondary transition focus-visible:ring-2 outline-0 focus-visible:text-text-primary focus-visible:ring-ring rounded h-full px-2"
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
class="text-text-tertiary hover:text-text-secondary"
|
||||||
@click="showMassUpdateModal = true">
|
@click="showMassUpdateModal = true">
|
||||||
<PencilSquareIcon class="w-4"></PencilSquareIcon>
|
<PencilSquareIcon class="w-4"></PencilSquareIcon>
|
||||||
<span> Edit </span>
|
<span>Edit</span>
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
v-if="selectedTimeEntries.length"
|
v-if="selectedTimeEntries.length"
|
||||||
class="text-red-400 h-full px-2 space-x-1 items-center flex hover:text-red-500 transition focus-visible:ring-2 outline-0 focus-visible:text-red-500 focus-visible:ring-ring rounded"
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
class="text-red-400 hover:text-red-500 hover:bg-red-500/10"
|
||||||
@click="deleteSelected">
|
@click="deleteSelected">
|
||||||
<TrashIcon class="w-3.5"></TrashIcon>
|
<TrashIcon class="w-3.5"></TrashIcon>
|
||||||
<span> Delete </span>
|
<span>Delete</span>
|
||||||
</button>
|
</Button>
|
||||||
</MainContainer>
|
</MainContainer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -664,7 +664,8 @@ const showCreateProject = ref(false);
|
|||||||
:organization-billable-rate="organizationBillableRate"
|
:organization-billable-rate="organizationBillableRate"
|
||||||
:currency="currency"
|
:currency="currency"
|
||||||
:clients="clients"
|
:clients="clients"
|
||||||
:create-project></ProjectCreateModal>
|
:create-project
|
||||||
|
:initial-project-name="searchValue"></ProjectCreateModal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
|||||||
v-bind="{ ...forwarded, ...$attrs }"
|
v-bind="{ ...forwarded, ...$attrs }"
|
||||||
:class="
|
:class="
|
||||||
cn(
|
cn(
|
||||||
'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
'z-50 overflow-hidden rounded-md shadow-dropdown border border-border bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
props.class
|
props.class
|
||||||
)
|
)
|
||||||
">
|
">
|
||||||
|
|||||||
@@ -147,6 +147,23 @@ export function getLocalizedDayJs(timestamp?: string | null) {
|
|||||||
return dayjs.utc(timestamp).tz(getUserTimezone());
|
return dayjs.utc(timestamp).tz(getUserTimezone());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a dayjs instance for a specific wall-clock time on a given day.
|
||||||
|
* Sets hour/minute directly to avoid DST issues with .add(minutes) on
|
||||||
|
* transition days. Negative or overflow values are normalised by shifting
|
||||||
|
* whole days (`.add(n, 'day')` is DST-safe).
|
||||||
|
*/
|
||||||
|
export function getLocalizedDayJsFromMinutes(dayStr: string, minutesFromMidnight: number) {
|
||||||
|
const dayOffset = Math.floor(minutesFromMidnight / (24 * 60));
|
||||||
|
const remainder = minutesFromMidnight - dayOffset * 24 * 60;
|
||||||
|
return dayjs
|
||||||
|
.tz(`${dayStr}T00:00:00`, getUserTimezone())
|
||||||
|
.add(dayOffset, 'day')
|
||||||
|
.hour(Math.floor(remainder / 60))
|
||||||
|
.minute(Math.round(remainder % 60))
|
||||||
|
.second(0);
|
||||||
|
}
|
||||||
|
|
||||||
export function getLocalizedDateFromTimestamp(timestamp: string) {
|
export function getLocalizedDateFromTimestamp(timestamp: string) {
|
||||||
return getLocalizedDayJs(timestamp).format('YYYY-MM-DD');
|
return getLocalizedDayJs(timestamp).format('YYYY-MM-DD');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ use App\Models\TimeEntry;
|
|||||||
use App\Service\CurrencyService;
|
use App\Service\CurrencyService;
|
||||||
use App\Service\Dto\ReportPropertiesDto;
|
use App\Service\Dto\ReportPropertiesDto;
|
||||||
use App\Service\TimeEntryFilter;
|
use App\Service\TimeEntryFilter;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Tests\Unit\Endpoint\Api\V1\ApiEndpointTestAbstract;
|
use Tests\Unit\Endpoint\Api\V1\ApiEndpointTestAbstract;
|
||||||
|
|
||||||
@@ -82,10 +83,11 @@ class PublicReportEndpointTest extends ApiEndpointTestAbstract
|
|||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
$timezone = 'Europe/Vienna';
|
$timezone = 'Europe/Vienna';
|
||||||
|
$now = Carbon::now($timezone);
|
||||||
$reportDto = new ReportPropertiesDto;
|
$reportDto = new ReportPropertiesDto;
|
||||||
$organization = Organization::factory()->create();
|
$organization = Organization::factory()->create();
|
||||||
$reportDto->start = now()->subDays(2);
|
$reportDto->start = $now->copy()->subDays(2);
|
||||||
$reportDto->end = now();
|
$reportDto->end = $now->copy();
|
||||||
$reportDto->group = TimeEntryAggregationType::Project;
|
$reportDto->group = TimeEntryAggregationType::Project;
|
||||||
$reportDto->subGroup = TimeEntryAggregationType::Task;
|
$reportDto->subGroup = TimeEntryAggregationType::Task;
|
||||||
$reportDto->historyGroup = TimeEntryAggregationTypeInterval::Day;
|
$reportDto->historyGroup = TimeEntryAggregationTypeInterval::Day;
|
||||||
@@ -102,9 +104,9 @@ class PublicReportEndpointTest extends ApiEndpointTestAbstract
|
|||||||
$task2 = Task::factory()->forOrganization($organization)->forProject($project)->create([
|
$task2 = Task::factory()->forOrganization($organization)->forProject($project)->create([
|
||||||
'id' => '3c54796d-5ab4-41e1-8f30-aa61a0a919ae',
|
'id' => '3c54796d-5ab4-41e1-8f30-aa61a0a919ae',
|
||||||
]);
|
]);
|
||||||
TimeEntry::factory()->forOrganization($organization)->forTask($task1)->startWithDuration(now()->subDay(), 100)->create();
|
TimeEntry::factory()->forOrganization($organization)->forTask($task1)->startWithDuration($now->copy()->subDay(), 100)->create();
|
||||||
TimeEntry::factory()->forOrganization($organization)->forTask($task2)->startWithDuration(now()->subDay(), 100)->create();
|
TimeEntry::factory()->forOrganization($organization)->forTask($task2)->startWithDuration($now->copy()->subDay(), 100)->create();
|
||||||
TimeEntry::factory()->forOrganization($organization)->startWithDuration(now()->subDay(), 100)->create();
|
TimeEntry::factory()->forOrganization($organization)->startWithDuration($now->copy()->subDay(), 100)->create();
|
||||||
|
|
||||||
$currencyService = app(CurrencyService::class);
|
$currencyService = app(CurrencyService::class);
|
||||||
|
|
||||||
@@ -193,7 +195,7 @@ class PublicReportEndpointTest extends ApiEndpointTestAbstract
|
|||||||
'grouped_type' => TimeEntryAggregationTypeInterval::Day->value,
|
'grouped_type' => TimeEntryAggregationTypeInterval::Day->value,
|
||||||
'grouped_data' => [
|
'grouped_data' => [
|
||||||
[
|
[
|
||||||
'key' => now()->timezone($timezone)->subDays(2)->toDateString(),
|
'key' => $now->copy()->subDays(2)->toDateString(),
|
||||||
'seconds' => 0,
|
'seconds' => 0,
|
||||||
'cost' => 0,
|
'cost' => 0,
|
||||||
'grouped_type' => null,
|
'grouped_type' => null,
|
||||||
@@ -202,7 +204,7 @@ class PublicReportEndpointTest extends ApiEndpointTestAbstract
|
|||||||
'color' => null,
|
'color' => null,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'key' => now()->timezone($timezone)->subDays(1)->toDateString(),
|
'key' => $now->copy()->subDays(1)->toDateString(),
|
||||||
'seconds' => 300,
|
'seconds' => 300,
|
||||||
'cost' => 0,
|
'cost' => 0,
|
||||||
'grouped_type' => null,
|
'grouped_type' => null,
|
||||||
@@ -211,7 +213,7 @@ class PublicReportEndpointTest extends ApiEndpointTestAbstract
|
|||||||
'color' => null,
|
'color' => null,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'key' => now()->timezone($timezone)->toDateString(),
|
'key' => $now->toDateString(),
|
||||||
'seconds' => 0,
|
'seconds' => 0,
|
||||||
'cost' => 0,
|
'cost' => 0,
|
||||||
'grouped_type' => null,
|
'grouped_type' => null,
|
||||||
@@ -332,6 +334,7 @@ class PublicReportEndpointTest extends ApiEndpointTestAbstract
|
|||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
$timezone = 'Europe/Vienna';
|
$timezone = 'Europe/Vienna';
|
||||||
|
$now = Carbon::now($timezone);
|
||||||
$organization = Organization::factory()->create();
|
$organization = Organization::factory()->create();
|
||||||
$client = Client::factory()->forOrganization($organization)->create();
|
$client = Client::factory()->forOrganization($organization)->create();
|
||||||
$project = Project::factory()->forClient($client)->forOrganization($organization)->create();
|
$project = Project::factory()->forClient($client)->forOrganization($organization)->create();
|
||||||
@@ -341,14 +344,14 @@ class PublicReportEndpointTest extends ApiEndpointTestAbstract
|
|||||||
TimeEntry::factory()->forOrganization($organization)
|
TimeEntry::factory()->forOrganization($organization)
|
||||||
->forTask($task)
|
->forTask($task)
|
||||||
->billable()
|
->billable()
|
||||||
->startWithDuration(now()->subDay(), 100)
|
->startWithDuration($now->copy()->subDay(), 100)
|
||||||
->create([
|
->create([
|
||||||
'tags' => [$tag->getKey()],
|
'tags' => [$tag->getKey()],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$reportDto = new ReportPropertiesDto;
|
$reportDto = new ReportPropertiesDto;
|
||||||
$reportDto->start = now()->subDays(2);
|
$reportDto->start = $now->copy()->subDays(2);
|
||||||
$reportDto->end = now();
|
$reportDto->end = $now->copy();
|
||||||
$reportDto->group = TimeEntryAggregationType::Project;
|
$reportDto->group = TimeEntryAggregationType::Project;
|
||||||
$reportDto->subGroup = TimeEntryAggregationType::Task;
|
$reportDto->subGroup = TimeEntryAggregationType::Task;
|
||||||
$reportDto->historyGroup = TimeEntryAggregationTypeInterval::Day;
|
$reportDto->historyGroup = TimeEntryAggregationTypeInterval::Day;
|
||||||
@@ -394,7 +397,7 @@ class PublicReportEndpointTest extends ApiEndpointTestAbstract
|
|||||||
'grouped_type' => TimeEntryAggregationTypeInterval::Day->value,
|
'grouped_type' => TimeEntryAggregationTypeInterval::Day->value,
|
||||||
'grouped_data' => [
|
'grouped_data' => [
|
||||||
[
|
[
|
||||||
'key' => now()->timezone($timezone)->subDays(2)->toDateString(),
|
'key' => $now->copy()->subDays(2)->toDateString(),
|
||||||
'seconds' => 0,
|
'seconds' => 0,
|
||||||
'cost' => 0,
|
'cost' => 0,
|
||||||
'grouped_type' => null,
|
'grouped_type' => null,
|
||||||
@@ -403,7 +406,7 @@ class PublicReportEndpointTest extends ApiEndpointTestAbstract
|
|||||||
'color' => null,
|
'color' => null,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'key' => now()->timezone($timezone)->subDays(1)->toDateString(),
|
'key' => $now->copy()->subDays(1)->toDateString(),
|
||||||
'seconds' => 0,
|
'seconds' => 0,
|
||||||
'cost' => 0,
|
'cost' => 0,
|
||||||
'grouped_type' => null,
|
'grouped_type' => null,
|
||||||
@@ -412,7 +415,7 @@ class PublicReportEndpointTest extends ApiEndpointTestAbstract
|
|||||||
'color' => null,
|
'color' => null,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'key' => now()->timezone($timezone)->toDateString(),
|
'key' => $now->toDateString(),
|
||||||
'seconds' => 0,
|
'seconds' => 0,
|
||||||
'cost' => 0,
|
'cost' => 0,
|
||||||
'grouped_type' => null,
|
'grouped_type' => null,
|
||||||
|
|||||||
Reference in New Issue
Block a user