Compare commits

...

5 Commits

Author SHA1 Message Date
Gregor Vostrak
7f75e5f1b5 set min release age for npm packages to 7 days to prevent supply chain attacks 2026-05-12 19:21:21 +02:00
Gregor Vostrak
8941aec72b remove timetrackerprojecttaskdropdown test without setup 2026-05-12 17:33:26 +02:00
Gregor Vostrak
bc1f4deaa0 fix DST boundary issue in timesheets 2026-05-12 17:25:46 +02:00
Gregor Vostrak
d292396dd4 fix "No project" duplicating rows, unify no project senitel to null 2026-05-12 17:02:26 +02:00
Gregor Vostrak
f926e8fde3 change TimeEntryFilter start filter to be inclusive 2026-05-12 16:55:01 +02:00
7 changed files with 67 additions and 8 deletions

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
min-release-age=7

View File

@@ -62,7 +62,7 @@ class TimeEntryFilter
if ($start === null) {
return $this;
}
$this->builder->where('start', '>', $start);
$this->builder->where('start', '>=', $start);
return $this;
}

View File

@@ -123,6 +123,7 @@ const emit = defineEmits<{
:create-project="createProject"
:create-client="createClient"
:organization-billable-rate="organization?.billable_rate ?? null"
:no-project-value="null"
@changed="(p, t) => emit('add-row', p, t)">
<template #trigger>
<Button variant="ghost" size="sm" class="text-text-secondary">

View File

@@ -81,6 +81,7 @@ function hasRunningEntry(dayIndex: number): boolean {
:create-project="createProject"
:create-client="createClient"
:organization-billable-rate="organization?.billable_rate ?? null"
:no-project-value="null"
variant="ghost"
size="sm"
class="w-full" />

View File

@@ -16,6 +16,8 @@ import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue
import { twMerge } from 'tailwind-merge';
import { Button } from '@/packages/ui/src/Buttons';
const NO_PROJECT_ID = '';
const task = defineModel<string | null>('task', {
default: null,
});
@@ -57,6 +59,7 @@ const props = withDefaults(
currency: string;
emptyPlaceholder?: string;
allowReset?: boolean;
noProjectValue?: string | null;
enableEstimatedTime: boolean;
organizationBillableRate: number | null;
canCreateProject: boolean;
@@ -68,6 +71,7 @@ const props = withDefaults(
{
emptyPlaceholder: 'No Project',
allowReset: false,
noProjectValue: NO_PROJECT_ID,
variant: 'ghost',
align: 'center',
size: 'sm',
@@ -164,10 +168,10 @@ function updateFilteredResults() {
is_archived: false,
projects: [
{
id: '',
id: NO_PROJECT_ID,
name: 'No Project',
color: 'var(--theme-color-icon-default)',
value: '',
value: NO_PROJECT_ID,
client_id: null,
billable_rate: null,
is_archived: false,
@@ -490,7 +494,7 @@ function selectTask(taskId: string) {
}
function selectProject(projectId: string) {
project.value = projectId;
project.value = projectId === NO_PROJECT_ID ? props.noProjectValue : projectId;
task.value = null;
open.value = false;
searchValue.value = '';

View File

@@ -26,6 +26,15 @@ interface Interval {
end: Dayjs;
}
function localDayBounds(date: string, tz: string): { dayStart: Dayjs; dayEnd: Dayjs } {
const dayjs = getDayJsInstance();
const localDayStart = dayjs.tz(`${date} 00:00:00`, tz);
return {
dayStart: localDayStart.utc(),
dayEnd: localDayStart.add(1, 'day').utc(),
};
}
/**
* Collect entries that intersect the day `[dayStart, dayEnd)`, clipped
* to those bounds. Running entries use `nowDayjs` as their end.
@@ -76,8 +85,7 @@ export function findFreeWindowOnDay(
if (requiredSeconds <= 0) return null;
const dayjs = getDayJsInstance();
const dayStart = dayjs.tz(`${date} 00:00:00`, tz).utc();
const dayEnd = dayStart.add(1, 'day');
const { dayStart, dayEnd } = localDayBounds(date, tz);
if (requiredSeconds > dayEnd.diff(dayStart, 'second')) return null;
@@ -149,8 +157,7 @@ export function freeGapSecondsAfter(
now?: string | Dayjs
): number {
const dayjs = getDayJsInstance();
const dayStart = dayjs.tz(`${date} 00:00:00`, tz).utc();
const dayEnd = dayStart.add(1, 'day');
const { dayStart, dayEnd } = localDayBounds(date, tz);
const cursorDjs = dayjs.utc(cursor);
if (cursorDjs.isSameOrAfter(dayEnd)) return 0;

View File

@@ -10,12 +10,57 @@ use App\Models\Tag;
use App\Models\Task;
use App\Models\TimeEntry;
use App\Service\TimeEntryFilter;
use Illuminate\Support\Carbon;
use PHPUnit\Framework\Attributes\CoversClass;
use Tests\TestCaseWithDatabase;
#[CoversClass(TimeEntryFilter::class)]
class TimeEntryFilterTest extends TestCaseWithDatabase
{
public function test_add_start_is_inclusive_of_boundary(): void
{
// Arrange
$boundary = Carbon::parse('2024-01-01 12:00:00', 'UTC');
$entryAtBoundary = TimeEntry::factory()->start($boundary)->create();
$entryAfterBoundary = TimeEntry::factory()->start($boundary->copy()->addSecond())->create();
$entryBeforeBoundary = TimeEntry::factory()->start($boundary->copy()->subSecond())->create();
$builder = TimeEntry::query();
$filter = new TimeEntryFilter($builder);
// Act
$filter->addStart($boundary);
// Assert
$timeEntries = $builder->get();
$this->assertCount(2, $timeEntries);
$this->assertTrue($timeEntries->contains($entryAtBoundary));
$this->assertTrue($timeEntries->contains($entryAfterBoundary));
$this->assertFalse($timeEntries->contains($entryBeforeBoundary));
}
public function test_add_end_is_exclusive_of_boundary(): void
{
// Arrange
$boundary = Carbon::parse('2024-01-01 12:00:00', 'UTC');
$entryAtBoundary = TimeEntry::factory()->start($boundary)->create();
$entryAfterBoundary = TimeEntry::factory()->start($boundary->copy()->addSecond())->create();
$entryBeforeBoundary = TimeEntry::factory()->start($boundary->copy()->subSecond())->create();
$builder = TimeEntry::query();
$filter = new TimeEntryFilter($builder);
// Act
$filter->addEnd($boundary);
// Assert
$timeEntries = $builder->get();
$this->assertCount(1, $timeEntries);
$this->assertTrue($timeEntries->contains($entryBeforeBoundary));
$this->assertFalse($timeEntries->contains($entryAtBoundary));
$this->assertFalse($timeEntries->contains($entryAfterBoundary));
}
public function test_add_tag_ids_filter_is_or(): void
{
// Arrange