Compare commits

...

16 Commits

Author SHA1 Message Date
dependabot[bot]
048b2e0204 Bump the major-updates group across 1 directory with 12 updates
Bumps the major-updates group with 12 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@tanstack/vue-query-devtools](https://github.com/TanStack/query/tree/HEAD/packages/vue-query-devtools) | `5.91.0` | `6.1.10` |
| [lucide-vue-next](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-vue-next) | `0.487.0` | `1.0.0` |
| [tailwind-merge](https://github.com/dcastil/tailwind-merge) | `2.6.1` | `3.5.0` |
| [@eslint/js](https://github.com/eslint/eslint/tree/HEAD/packages/js) | `9.39.2` | `10.0.1` |
| [@inertiajs/vue3](https://github.com/inertiajs/inertia/tree/HEAD/packages/vue3) | `2.3.13` | `3.0.0` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `22.19.7` | `25.5.0` |
| [laravel-vite-plugin](https://github.com/laravel/vite-plugin) | `2.1.0` | `3.0.0` |
| [postcss-import](https://github.com/postcss/postcss-import) | `15.1.0` | `16.1.1` |
| [postcss-nesting](https://github.com/csstools/postcss-plugins/tree/HEAD/plugins/postcss-nesting) | `12.1.5` | `14.0.0` |
| [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `3.4.19` | `4.2.2` |
| [typescript](https://github.com/microsoft/TypeScript) | `5.9.3` | `6.0.2` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `7.3.1` | `8.0.3` |



Updates `@tanstack/vue-query-devtools` from 5.91.0 to 6.1.10
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/vue-query-devtools/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/vue-query-devtools@6.1.10/packages/vue-query-devtools)

Updates `lucide-vue-next` from 0.487.0 to 1.0.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.0.0/packages/lucide-vue-next)

Updates `tailwind-merge` from 2.6.1 to 3.5.0
- [Release notes](https://github.com/dcastil/tailwind-merge/releases)
- [Commits](https://github.com/dcastil/tailwind-merge/compare/v2.6.1...v3.5.0)

Updates `@eslint/js` from 9.39.2 to 10.0.1
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/commits/v10.0.1/packages/js)

Updates `@inertiajs/vue3` from 2.3.13 to 3.0.0
- [Release notes](https://github.com/inertiajs/inertia/releases)
- [Changelog](https://github.com/inertiajs/inertia/blob/3.x/CHANGELOG.md)
- [Commits](https://github.com/inertiajs/inertia/commits/v3.0.0/packages/vue3)

Updates `@types/node` from 22.19.7 to 25.5.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `laravel-vite-plugin` from 2.1.0 to 3.0.0
- [Release notes](https://github.com/laravel/vite-plugin/releases)
- [Changelog](https://github.com/laravel/vite-plugin/blob/3.x/CHANGELOG.md)
- [Upgrade guide](https://github.com/laravel/vite-plugin/blob/3.x/UPGRADE.md)
- [Commits](https://github.com/laravel/vite-plugin/compare/v2.1.0...v3.0.0)

Updates `postcss-import` from 15.1.0 to 16.1.1
- [Release notes](https://github.com/postcss/postcss-import/releases)
- [Changelog](https://github.com/postcss/postcss-import/blob/master/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss-import/compare/15.1.0...16.1.1)

Updates `postcss-nesting` from 12.1.5 to 14.0.0
- [Changelog](https://github.com/csstools/postcss-plugins/blob/main/plugins/postcss-nesting/CHANGELOG.md)
- [Commits](https://github.com/csstools/postcss-plugins/commits/HEAD/plugins/postcss-nesting)

Updates `tailwindcss` from 3.4.19 to 4.2.2
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.2.2/packages/tailwindcss)

Updates `typescript` from 5.9.3 to 6.0.2
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.9.3...v6.0.2)

Updates `vite` from 7.3.1 to 8.0.3
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/create-vite@8.0.3/packages/vite)

---
updated-dependencies:
- dependency-name: "@tanstack/vue-query-devtools"
  dependency-version: 6.1.10
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: lucide-vue-next
  dependency-version: 1.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: tailwind-merge
  dependency-version: 3.5.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: "@eslint/js"
  dependency-version: 10.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: "@inertiajs/vue3"
  dependency-version: 3.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: "@types/node"
  dependency-version: 25.5.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: laravel-vite-plugin
  dependency-version: 3.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: postcss-import
  dependency-version: 16.1.1
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: postcss-nesting
  dependency-version: 14.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: tailwindcss
  dependency-version: 4.2.2
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: typescript
  dependency-version: 6.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: vite
  dependency-version: 8.0.3
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-13 08:31:23 +00:00
Gregor Vostrak
84cd0d572d bump ui package version 2026-04-08 23:18:29 +02:00
Gregor Vostrak
f37b86f377 chore: remove unused formatActivityDuration function 2026-04-08 14:49:37 +02:00
Gregor Vostrak
1e7364fc4b show calendar activities more prominently when no time entry exists 2026-04-08 14:43:09 +02:00
Gregor Vostrak
8cbc9838c9 fix minimal layout shift on time entry select and migrate to ui button 2026-04-07 21:42:34 +02:00
Gregor Vostrak
71c8992e31 Fix getLocalizedDayJsFromMinutes handling negative minute values 2026-03-31 13:56:30 +02:00
Gregor Vostrak
53d91b65d6 fix: use timezoned dates in public report endpoint tests
Replace travelTo + now() with Carbon::now($timezone)->startOfDay() to eliminate flakiness when tests run near midnight UTC, where the UTC and Vienna dates can differ.
2026-03-31 13:21:54 +02:00
Gregor Vostrak
0c88a10eb5 improve calendar current day styling 2026-03-30 00:58:40 +02:00
Gregor Vostrak
dd7b23958a fix gotenberg url in CI 2026-03-30 00:07:57 +02:00
Gregor Vostrak
1eb066f5aa Add E2E test for project name prefill 2026-03-29 23:55:10 +02:00
ShrootBuck
b1287c6a0a Prefill project name in create modal
Add optional initialProjectName prop to ProjectCreateModal and use it
to initialize the project's name. Pass the TimeTracker dropdown's
searchValue as initial-project-name so the create form is prefilled.
2026-03-29 23:55:10 +02:00
Gregor Vostrak
815abb5980 improve drag handle hit area and activity tooltip placement 2026-03-29 23:14:01 +02:00
Gregor Vostrak
e2f859be27 fix calendar scroll down on load; bump ui package version 2026-03-29 23:02:22 +02:00
Gregor Vostrak
3d26fcaefe Fix DST-related timezone offset when creating/resizing/dragging calendar
events
2026-03-29 22:55:50 +02:00
Gregor Vostrak
1e73a90f9d chore: bump ui version 2026-03-29 22:09:01 +02:00
Gregor Vostrak
0f8f906e5c clarify naming on activity type 2026-03-27 00:37:29 +01:00
20 changed files with 1250 additions and 1701 deletions

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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",

View File

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

View File

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

View File

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

View File

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

View File

@@ -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() };
} }

View File

@@ -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>> = {};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
) )
"> ">

View File

@@ -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');
} }

View File

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