Compare commits

..

2 Commits

Author SHA1 Message Date
Gregor Vostrak
eb7233dfba anchor cross-day calendar e2e tests to Wednesday and Thursday
The tests used today and tomorrow (or yesterday) and skipped when those fell outside the visible week. The Saturday skips were also wrong for the Monday week start e2e users get, so the tests failed whenever CI ran on a Sunday. Using Wednesday and Thursday of the current week keeps both days visible whichever day the suite runs, so the day-of-week skips are gone.
2026-09-28 17:18:40 +02:00
Gregor Vostrak
398a81798b move project task count into its own sortable column
Also removes the ring around the project color dot, shrinks it slightly and tightens its spacing to the name.
2026-09-28 17:18:40 +02:00
71 changed files with 627 additions and 1403 deletions

View File

@@ -141,7 +141,7 @@ jobs:
${{ env.DOCKER_REPO }} ${{ env.DOCKER_REPO }}
- name: "Login to solidtime OnPremise Registry" - name: "Login to solidtime OnPremise Registry"
uses: docker/login-action@v4.6.0 uses: docker/login-action@v4.5.2
with: with:
registry: registry.on-premise.solidtime.io registry: registry.on-premise.solidtime.io
username: ${{ secrets.ONPREMISE_USERNAME }} username: ${{ secrets.ONPREMISE_USERNAME }}
@@ -195,7 +195,7 @@ jobs:
merge-multiple: true merge-multiple: true
- name: "Login to solidtime OnPremise Registry" - name: "Login to solidtime OnPremise Registry"
uses: docker/login-action@v4.6.0 uses: docker/login-action@v4.5.2
with: with:
registry: registry.on-premise.solidtime.io registry: registry.on-premise.solidtime.io
username: ${{ secrets.ONPREMISE_USERNAME }} username: ${{ secrets.ONPREMISE_USERNAME }}

View File

@@ -97,7 +97,7 @@ jobs:
- name: "Install dependencies in billing extension" - name: "Install dependencies in billing extension"
uses: php-actions/composer@v6 uses: php-actions/composer@v6
env: env:
COMPOSER_AUTH: '{"http-basic": {"spark.laravel.com": {"username": "${{ secrets.LARAVEL_SPARK_USERNAME }}", "password": "${{ secrets.LARAVEL_SPARK_API_KEY }}"}}}' COMPOSER_AUTH: '{"http-basic": {"spark.laravel.com": {"username": "gregor@vostrak.at", "password": "${{ secrets.LARAVEL_SPARK_API_KEY }}"}}}'
with: with:
working_dir: "extensions/Billing" working_dir: "extensions/Billing"
command: install command: install
@@ -177,7 +177,7 @@ jobs:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
- name: "Login to GitHub Container Registry" - name: "Login to GitHub Container Registry"
uses: docker/login-action@v4.6.0 uses: docker/login-action@v4.5.2
with: with:
registry: rg.fr-par.scw.cloud/solidtime registry: rg.fr-par.scw.cloud/solidtime
username: nologin username: nologin

View File

@@ -117,13 +117,13 @@ jobs:
${{ env.GHCR_REPO }} ${{ env.GHCR_REPO }}
- name: "Login to Docker Hub Container Registry" - name: "Login to Docker Hub Container Registry"
uses: docker/login-action@v4.6.0 uses: docker/login-action@v4.5.2
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: "Login to GitHub Container Registry" - name: "Login to GitHub Container Registry"
uses: docker/login-action@v4.6.0 uses: docker/login-action@v4.5.2
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
@@ -177,13 +177,13 @@ jobs:
merge-multiple: true merge-multiple: true
- name: "Login to Docker Hub" - name: "Login to Docker Hub"
uses: docker/login-action@v4.6.0 uses: docker/login-action@v4.5.2
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: "Login to GHCR" - name: "Login to GHCR"
uses: docker/login-action@v4.6.0 uses: docker/login-action@v4.5.2
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}

View File

@@ -1,136 +0,0 @@
name: PHPUnit Tests - Extensions
on: push
permissions:
contents: read
jobs:
phpunit-extensions:
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
matrix:
postgres_version: [ 15, 16, 17 ]
services:
pgsql_test:
image: postgres:${{ matrix.postgres_version }}
env:
PGPASSWORD: 'root'
POSTGRES_DB: 'laravel'
POSTGRES_USER: 'root'
POSTGRES_PASSWORD: 'root'
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
gotenberg:
image: gotenberg/gotenberg:8
ports:
- 3000:3000
options: >-
--health-cmd "curl --silent --fail http://localhost:3000/health"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: "Checkout code"
uses: actions/checkout@v7
- name: "Setup PHP"
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv
coverage: pcov
- uses: actions/setup-node@v7
with:
node-version: '20.x'
- name: "Read extension manifest"
id: extension-manifest
run: |
{
echo "billing_repository=$(jq -r '.Billing.repository' extensions/manifest.json)"
echo "billing_ref=$(jq -r '.Billing.ref' extensions/manifest.json)"
echo "services_repository=$(jq -r '.Services.repository' extensions/manifest.json)"
echo "services_ref=$(jq -r '.Services.ref' extensions/manifest.json)"
echo "invoicing_repository=$(jq -r '.Invoicing.repository' extensions/manifest.json)"
echo "invoicing_ref=$(jq -r '.Invoicing.ref' extensions/manifest.json)"
} >> "$GITHUB_OUTPUT"
- name: "Checkout billing extension"
uses: actions/checkout@v7
with:
repository: ${{ steps.extension-manifest.outputs.billing_repository }}
ref: ${{ steps.extension-manifest.outputs.billing_ref }}
path: extensions/Billing
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_BILLING_EXTENSION }}
- name: "Install composer dependencies in billing extension"
working-directory: extensions/Billing
env:
COMPOSER_AUTH: '{"http-basic": {"spark.laravel.com": {"username": "contact@solidtime.io", "password": "${{ secrets.LARAVEL_SPARK_API_KEY }}"}}}'
run: composer install -n --prefer-dist --ignore-platform-reqs
- name: "Install npm dependencies in billing extension"
run: cd extensions/Billing && npm ci
- name: "Checkout services extension"
uses: actions/checkout@v7
with:
repository: ${{ steps.extension-manifest.outputs.services_repository }}
ref: ${{ steps.extension-manifest.outputs.services_ref }}
path: extensions/Services
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_SERVICES_EXTENSION }}
- name: "Install composer dependencies in services extension"
working-directory: extensions/Services
run: composer install -n --prefer-dist --ignore-platform-reqs
- name: "Install npm dependencies in services extension"
run: cd extensions/Services && npm ci
- name: "Checkout invoicing extension"
uses: actions/checkout@v7
with:
repository: ${{ steps.extension-manifest.outputs.invoicing_repository }}
ref: ${{ steps.extension-manifest.outputs.invoicing_ref }}
path: extensions/Invoicing
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }}
- name: "Install composer dependencies in invoicing extension"
working-directory: extensions/Invoicing
run: composer install -n --prefer-dist --ignore-platform-reqs
- name: "Install npm dependencies in invoicing extension"
run: cd extensions/Invoicing && npm ci
- name: "Run composer install"
run: composer install -n --prefer-dist --ignore-platform-reqs
- name: "Activate billing extension"
run: php artisan module:enable Billing
- name: "Activate services extension"
run: php artisan module:enable Services
- name: "Activate invoicing extension"
run: php artisan module:enable Invoicing
- name: "Install dependencies"
run: npm ci
- name: "Build Frontend"
run: npm run build
- name: "Prepare Laravel Application"
run: |
cp .env.ci .env
php artisan key:generate
php artisan passport:keys
- name: "Run PHPUnit"
run: php artisan test extensions/Billing/tests extensions/Services/tests extensions/Invoicing/tests --stop-on-failure

View File

@@ -68,7 +68,7 @@ jobs:
run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml
- name: "Upload coverage reports to Codecov" - name: "Upload coverage reports to Codecov"
uses: codecov/codecov-action@v7.1.1 uses: codecov/codecov-action@v7.0.0
with: with:
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
slug: solidtime-io/solidtime slug: solidtime-io/solidtime

View File

@@ -44,7 +44,7 @@ class ClockifyProjectsImporter extends DefaultImporter
'organization_id' => $this->organization->id, 'organization_id' => $this->organization->id,
], [ ], [
'color' => $this->colorService->getRandomColor(), 'color' => $this->colorService->getRandomColor(),
'is_billable' => ($record['Billability'] ?? '') === 'Yes', 'is_billable' => $record['Billability'] === 'Yes',
'billable_rate' => $billableRateKey !== null && $record[$billableRateKey] !== '' ? (int) (((float) $record[$billableRateKey]) * 100) : null, 'billable_rate' => $billableRateKey !== null && $record[$billableRateKey] !== '' ? (int) (((float) $record[$billableRateKey]) * 100) : null,
'estimated_time' => isset($record['Estimated (h)']) && is_numeric($record['Estimated (h)']) ? (int) ($record['Estimated (h)'] * 3600) : null, 'estimated_time' => isset($record['Estimated (h)']) && is_numeric($record['Estimated (h)']) ? (int) ($record['Estimated (h)'] * 3600) : null,
'archived_at' => $record['Status'] === 'Archived' ? Carbon::now() : null, 'archived_at' => $record['Status'] === 'Archived' ? Carbon::now() : null,
@@ -54,7 +54,6 @@ class ClockifyProjectsImporter extends DefaultImporter
if ($tasksKey !== null && $record[$tasksKey] !== '') { if ($tasksKey !== null && $record[$tasksKey] !== '') {
$tasks = explode(', ', $record[$tasksKey]); $tasks = explode(', ', $record[$tasksKey]);
foreach ($tasks as $task) { foreach ($tasks as $task) {
$this->checkTaskNameLength($task);
$this->taskImportHelper->getKey([ $this->taskImportHelper->getKey([
'name' => $task, 'name' => $task,
'project_id' => $projectId, 'project_id' => $projectId,
@@ -84,6 +83,7 @@ class ClockifyProjectsImporter extends DefaultImporter
'Project', 'Project',
'Status', 'Status',
'Visibility', 'Visibility',
'Billability',
]; ];
foreach ($requiredFields as $requiredField) { foreach ($requiredFields as $requiredField) {
if (! in_array($requiredField, $header, true)) { if (! in_array($requiredField, $header, true)) {

View File

@@ -103,7 +103,6 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
} }
$taskId = null; $taskId = null;
if (! $isBreak && $taskKey !== null && $record[$taskKey] !== '') { if (! $isBreak && $taskKey !== null && $record[$taskKey] !== '') {
$this->checkTaskNameLength($record[$taskKey]);
$taskId = $this->taskImportHelper->getKey([ $taskId = $this->taskImportHelper->getKey([
'name' => $record[$taskKey], 'name' => $record[$taskKey],
'project_id' => $projectId, 'project_id' => $projectId,
@@ -228,6 +227,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
'Project', 'Project',
'Description', 'Description',
'User', 'User',
'Group',
'Email', 'Email',
'Tags', 'Tags',
'Start Date', 'Start Date',

View File

@@ -21,8 +21,6 @@ use Illuminate\Database\Eloquent\Builder;
abstract class DefaultImporter implements ImporterContract abstract class DefaultImporter implements ImporterContract
{ {
protected const TASK_NAME_MAX_LENGTH = 500;
protected Organization $organization; protected Organization $organization;
/** /**
@@ -183,16 +181,6 @@ abstract class DefaultImporter implements ImporterContract
$this->billableRateService = app(BillableRateService::class); $this->billableRateService = app(BillableRateService::class);
} }
/**
* @throws ImportException
*/
protected function checkTaskNameLength(string $taskName): void
{
if (strlen($taskName) > self::TASK_NAME_MAX_LENGTH) {
throw new ImportException('Task name ("'.$taskName.'") is too long, maximum length is '.self::TASK_NAME_MAX_LENGTH.' characters');
}
}
#[\Override] #[\Override]
public function getReport(): ReportDto public function getReport(): ReportDto
{ {

View File

@@ -113,7 +113,6 @@ class GenericTimeEntriesImporter extends DefaultImporter
} }
$taskId = null; $taskId = null;
if ($record['task'] !== '') { if ($record['task'] !== '') {
$this->checkTaskNameLength($record['task']);
$taskId = $this->taskImportHelper->getKey([ $taskId = $this->taskImportHelper->getKey([
'name' => $record['task'], 'name' => $record['task'],
'project_id' => $projectId, 'project_id' => $projectId,

View File

@@ -92,7 +92,6 @@ class HarvestTimeEntriesImporter extends DefaultImporter
} }
$taskId = null; $taskId = null;
if ($record['Task'] !== '') { if ($record['Task'] !== '') {
$this->checkTaskNameLength($record['Task']);
$taskId = $this->taskImportHelper->getKey([ $taskId = $this->taskImportHelper->getKey([
'name' => $record['Task'], 'name' => $record['Task'],
'project_id' => $projectId, 'project_id' => $projectId,

View File

@@ -199,7 +199,6 @@ class SolidtimeImporter extends DefaultImporter
if ($projectId === null) { if ($projectId === null) {
throw new Exception('Project does not exist'); throw new Exception('Project does not exist');
} }
$this->checkTaskNameLength($task['name']);
$this->taskImportHelper->getKey([ $this->taskImportHelper->getKey([
'name' => $task['name'], 'name' => $task['name'],
'project_id' => $projectId, 'project_id' => $projectId,

View File

@@ -153,16 +153,9 @@ class TogglDataImporter extends DefaultImporter
} }
foreach ($projectMembers as $projectMember) { foreach ($projectMembers as $projectMember) {
$userId = $this->userImportHelper->getKeyByExternalIdentifier((string) $projectMember->user_id); $userId = $this->userImportHelper->getKeyByExternalIdentifier((string) $projectMember->user_id);
if ($userId === null) {
throw new Exception('User does not exist');
}
$memberId = $this->memberImportHelper->getKeyByExternalIdentifier($userId);
if ($memberId === null) {
throw new Exception('Member does not exist');
}
$this->projectMemberImportHelper->getKey([ $this->projectMemberImportHelper->getKey([
'project_id' => $projectId, 'project_id' => $projectId,
'member_id' => $memberId, 'member_id' => $this->memberImportHelper->getKeyByExternalIdentifier($userId),
], [ ], [
'user_id' => $userId, 'user_id' => $userId,
'billable_rate' => $projectMember->rate !== null ? (int) ($projectMember->rate * 100) : null, 'billable_rate' => $projectMember->rate !== null ? (int) ($projectMember->rate * 100) : null,
@@ -189,7 +182,6 @@ class TogglDataImporter extends DefaultImporter
if ($projectId === null) { if ($projectId === null) {
throw new Exception('Project does not exist'); throw new Exception('Project does not exist');
} }
$this->checkTaskNameLength($task->name);
$this->taskImportHelper->getKey([ $this->taskImportHelper->getKey([
'name' => $task->name, 'name' => $task->name,
'project_id' => $projectId, 'project_id' => $projectId,

View File

@@ -97,7 +97,6 @@ class TogglTimeEntriesImporter extends DefaultImporter
} }
$taskId = null; $taskId = null;
if ($record['Task'] !== '') { if ($record['Task'] !== '') {
$this->checkTaskNameLength($record['Task']);
$taskId = $this->taskImportHelper->getKey([ $taskId = $this->taskImportHelper->getKey([
'name' => $record['Task'], 'name' => $record['Task'],
'project_id' => $projectId, 'project_id' => $projectId,

View File

@@ -1,6 +1,7 @@
ARG PHP_VERSION=8.3 ARG PHP_VERSION=8.3
ARG FRANKENPHP_VERSION=1.11 ARG FRANKENPHP_VERSION=1.8
ARG COMPOSER_VERSION=2.8 ARG COMPOSER_VERSION=2.8
ARG BUN_VERSION="latest"
ARG APP_ENV ARG APP_ENV
ARG DOCKER_FILES_BASE_PATH="docker/prod/" ARG DOCKER_FILES_BASE_PATH="docker/prod/"
@@ -15,13 +16,13 @@ RUN CGO_ENABLED=1 \
XCADDY_GO_BUILD_FLAGS="-ldflags='-w -s' -tags=nobadger,nomysql,nopgx" \ XCADDY_GO_BUILD_FLAGS="-ldflags='-w -s' -tags=nobadger,nomysql,nopgx" \
CGO_CFLAGS=$(php-config --includes) \ CGO_CFLAGS=$(php-config --includes) \
CGO_LDFLAGS="$(php-config --ldflags) $(php-config --libs)" \ CGO_LDFLAGS="$(php-config --ldflags) $(php-config --libs)" \
xcaddy build \ xcaddy build v2.10.0 \
--output /usr/local/bin/frankenphp \ --output /usr/local/bin/frankenphp \
--with github.com/dunglas/frankenphp=./ \ --with github.com/dunglas/frankenphp=./ \
--with github.com/dunglas/frankenphp/caddy=./caddy/ \ --with github.com/dunglas/frankenphp/caddy=./caddy/ \
--with github.com/dunglas/caddy-cbrotli --with github.com/dunglas/caddy-cbrotli
FROM dunglas/frankenphp:${FRANKENPHP_VERSION}-php${PHP_VERSION} FROM dunglas/frankenphp:${FRANKENPHP_VERSION}-php${PHP_VERSION} AS base
COPY --from=upstream /usr/local/bin/frankenphp /usr/local/bin/frankenphp COPY --from=upstream /usr/local/bin/frankenphp /usr/local/bin/frankenphp
@@ -31,28 +32,26 @@ LABEL org.opencontainers.image.description="solidtime is a modern open source ti
LABEL org.opencontainers.image.source="https://github.com/solidtime-io/solidtime" LABEL org.opencontainers.image.source="https://github.com/solidtime-io/solidtime"
LABEL org.opencontainers.image.licenses="AGPL" LABEL org.opencontainers.image.licenses="AGPL"
ARG USER_ID=1000 ARG WWWUSER=1000
ARG GROUP_ID=1000 ARG WWWGROUP=1000
ARG TZ=UTC ARG TZ=UTC
ARG APP_DIR=/var/www/html
ARG APP_ENV
ARG APP_HOST
ARG DOCKER_FILES_BASE_PATH ARG DOCKER_FILES_BASE_PATH
ENV DEBIAN_FRONTEND=noninteractive \ ENV DEBIAN_FRONTEND=noninteractive \
TERM=xterm-color \ TERM=xterm-color \
OCTANE_SERVER=frankenphp \ OCTANE_SERVER=frankenphp \
TZ=${TZ} \ TZ=${TZ} \
LANG=C.UTF-8 \ USER=octane \
USER=laravel \ ROOT=${APP_DIR} \
ROOT=/var/www/html \ APP_ENV=${APP_ENV} \
APP_ENV=production \
COMPOSER_ALLOW_SUPERUSER=1 \
COMPOSER_FUND=0 \ COMPOSER_FUND=0 \
COMPOSER_MAX_PARALLEL_HTTP=48 \ COMPOSER_MAX_PARALLEL_HTTP=24 \
WITH_HORIZON=false \ XDG_CONFIG_HOME=${APP_DIR}/.config \
WITH_SCHEDULER=false \ XDG_DATA_HOME=${APP_DIR}/.data \
WITH_REVERB=false \ SERVER_NAME=${APP_HOST}
WITH_SSR=false
ENV XDG_CONFIG_HOME=${ROOT}/.config XDG_DATA_HOME=${ROOT}/.data
WORKDIR ${ROOT} WORKDIR ${ROOT}
@@ -61,9 +60,6 @@ SHELL ["/bin/bash", "-eou", "pipefail", "-c"]
RUN ln -snf /usr/share/zoneinfo/${TZ} /etc/localtime \ RUN ln -snf /usr/share/zoneinfo/${TZ} /etc/localtime \
&& echo ${TZ} > /etc/timezone && echo ${TZ} > /etc/timezone
RUN echo "Acquire::http::No-Cache true;" >> /etc/apt/apt.conf.d/99custom && \
echo "Acquire::BrokenProxy true;" >> /etc/apt/apt.conf.d/99custom
RUN apt-get update; \ RUN apt-get update; \
apt-get upgrade -yqq; \ apt-get upgrade -yqq; \
apt-get install -yqq --no-install-recommends --show-progress \ apt-get install -yqq --no-install-recommends --show-progress \
@@ -72,36 +68,40 @@ RUN apt-get update; \
wget \ wget \
vim \ vim \
git \ git \
unzip \
ncdu \ ncdu \
procps \ procps \
unzip \
ca-certificates \ ca-certificates \
supervisor \ supervisor \
libsodium-dev \ libsodium-dev \
# && curl -fsSL https://bun.sh/install | BUN_INSTALL=/usr bash \ libbrotli-dev \
# Install PHP extensions (included with dunglas/frankenphp)
&& install-php-extensions \ && install-php-extensions \
apcu \
bz2 \ bz2 \
pcntl \ pcntl \
mbstring \ mbstring \
bcmath \ bcmath \
sockets \ sockets \
pgsql \
pdo_pgsql \ pdo_pgsql \
opcache \ opcache \
exif \ exif \
pdo_mysql \ pdo_mysql \
zip \ zip \
uv \ uv \
vips \
intl \ intl \
gd \ gd \
redis \ redis \
rdkafka \ rdkafka \
ffi \ memcached \
igbinary \
ldap \ ldap \
&& apt-get -y autoremove \ && apt-get -y autoremove \
&& apt-get clean \ && apt-get clean \
&& docker-php-source delete \ && docker-php-source delete \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* /var/log/lastlog /var/log/faillog && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* \
&& rm /var/log/lastlog /var/log/faillog
RUN arch="$(uname -m)" \ RUN arch="$(uname -m)" \
&& case "$arch" in \ && case "$arch" in \
@@ -111,64 +111,106 @@ RUN arch="$(uname -m)" \
x86) _cronic_fname='supercronic-linux-386' ;; \ x86) _cronic_fname='supercronic-linux-386' ;; \
*) echo >&2 "error: unsupported architecture: $arch"; exit 1 ;; \ *) echo >&2 "error: unsupported architecture: $arch"; exit 1 ;; \
esac \ esac \
&& wget -q "https://github.com/aptible/supercronic/releases/download/v0.2.38/${_cronic_fname}" \ && wget -q "https://github.com/aptible/supercronic/releases/download/v0.2.29/${_cronic_fname}" \
-O /usr/bin/supercronic \ -O /usr/bin/supercronic \
&& chmod +x /usr/bin/supercronic \ && chmod +x /usr/bin/supercronic \
&& mkdir -p /etc/supercronic \ && mkdir -p /etc/supercronic \
&& echo "*/1 * * * * php ${ROOT}/artisan schedule:run --no-interaction" > /etc/supercronic/laravel && echo "*/1 * * * * php ${ROOT}/artisan schedule:run --no-interaction" > /etc/supercronic/laravel
RUN userdel --remove --force www-data \ RUN userdel --remove --force www-data \
&& groupadd --force -g ${GROUP_ID} ${USER} \ && groupadd --force -g ${WWWGROUP} ${USER} \
&& useradd -ms /bin/bash --no-log-init --no-user-group -g ${GROUP_ID} -u ${USER_ID} ${USER} && useradd -ms /bin/bash --no-log-init --no-user-group -g ${WWWGROUP} -u ${WWWUSER} ${USER} \
&& setcap -r /usr/local/bin/frankenphp
RUN chown -R ${USER}:${USER} ${ROOT} /var/{log,run} \
&& chmod -R a+rw ${ROOT} /var/{log,run}
RUN cp ${PHP_INI_DIR}/php.ini-production ${PHP_INI_DIR}/php.ini RUN cp ${PHP_INI_DIR}/php.ini-production ${PHP_INI_DIR}/php.ini
COPY --link --from=vendor /usr/bin/composer /usr/bin/composer USER ${USER}
COPY --link ${DOCKER_FILES_BASE_PATH}deployment/supervisord.conf /etc/
COPY --link ${DOCKER_FILES_BASE_PATH}deployment/octane/FrankenPHP/supervisord.frankenphp.conf /etc/supervisor/conf.d/
COPY --link ${DOCKER_FILES_BASE_PATH}deployment/supervisord.*.conf /etc/supervisor/conf.d/
COPY --link ${DOCKER_FILES_BASE_PATH}deployment/start-container /usr/local/bin/start-container
COPY --link ${DOCKER_FILES_BASE_PATH}deployment/healthcheck /usr/local/bin/healthcheck
COPY --link ${DOCKER_FILES_BASE_PATH}deployment/php.ini ${PHP_INI_DIR}/conf.d/99-php.ini
#COPY --link composer.* ./
COPY --link --chown=${WWWUSER}:${WWWUSER} --from=vendor /usr/bin/composer /usr/bin/composer
COPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/supervisord.conf /etc/
COPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/octane/FrankenPHP/supervisord.frankenphp.conf /etc/supervisor/conf.d/
COPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/supervisord.*.conf /etc/supervisor/conf.d/
COPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/start-container /usr/local/bin/start-container
COPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/healthcheck /usr/local/bin/healthcheck
COPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/php.ini ${PHP_INI_DIR}/conf.d/99-octane.ini
RUN chmod +x /usr/local/bin/start-container /usr/local/bin/healthcheck
###########################################
#FROM base AS common
#
#USER ${USER}
#
#COPY --link --chown=${WWWUSER}:${WWWUSER} . .
#
#RUN composer install \ #RUN composer install \
# --no-dev \ # --no-dev \
# --no-interaction \ # --no-interaction \
# --no-autoloader \ # --no-autoloader \
# --no-ansi \ # --no-ansi \
# --no-scripts \ # --no-scripts \
# --no-progress \
# --audit # --audit
###########################################
# Build frontend assets with Bun
###########################################
#FROM oven/bun:${BUN_VERSION} AS build
#
#ARG APP_ENV
#
#ENV ROOT=/var/www/html \
# APP_ENV=${APP_ENV} \
# NODE_ENV=${APP_ENV:-production}
#
#WORKDIR ${ROOT}
#
#COPY --link package.json bun.lock* ./ #COPY --link package.json bun.lock* ./
#
#RUN bun install --frozen-lockfile #RUN bun install --frozen-lockfile
#
#COPY --link . .
#COPY --link --from=common ${ROOT}/vendor vendor
#
#RUN bun run build
COPY --link . . ###########################################
#FROM common AS runner
USER ${USER}
ENV WITH_HORIZON=false \
WITH_SCHEDULER=false \
WITH_REVERB=false
COPY --link --chown=${WWWUSER}:${WWWUSER} . ./
RUN test -z "$(find . -name .git -print -quit)"
#COPY --link --chown=${WWWUSER}:${WWWUSER} --from=build ${ROOT}/public public
RUN mkdir -p \ RUN mkdir -p \
storage/framework/{sessions,views,cache,testing} \ storage/framework/{sessions,views,cache,testing} \
storage/logs \ storage/logs \
bootstrap/cache \ bootstrap/cache && chmod -R a+rw storage
&& chmod +x /usr/local/bin/start-container /usr/local/bin/healthcheck
RUN composer dump-autoload \ #RUN composer install \
--optimize \ # --classmap-authoritative \
--apcu \ # --no-interaction \
--no-dev # --no-ansi \
# --no-dev \
# && composer clear-cache
#RUN bun run build RUN cat .env
#RUN php artisan env
RUN chown -R ${USER_ID}:${GROUP_ID} ${ROOT} \
&& find / -perm /6000 -type f -exec chmod a-s {} + 2>/dev/null || true
USER ${USER}
EXPOSE 8000 EXPOSE 8000
#EXPOSE 2019
#EXPOSE 8080
ENTRYPOINT ["start-container"] ENTRYPOINT ["start-container"]
#HEALTHCHECK --start-period=30s --interval=10s --timeout=3s --retries=3 CMD healthcheck || exit 1 #HEALTHCHECK --start-period=5s --interval=2s --timeout=5s --retries=8 CMD healthcheck || exit 1

View File

@@ -22,13 +22,6 @@ elif [ "${container_mode}" = "reverb" ]; then
echo "Healthcheck failed." echo "Healthcheck failed."
exit 1 exit 1
fi fi
elif [ "${container_mode}" = "ssr" ]; then
if [ "$(supervisorctl status inertia-ssr-server:inertia-ssr-server_0 | awk '{print tolower($2)}')" = "running" ]; then
exit 0
else
echo "Healthcheck failed."
exit 1
fi
elif [ "${container_mode}" = "worker" ]; then elif [ "${container_mode}" = "worker" ]; then
if [ "$(supervisorctl status worker:worker_0 | awk '{print tolower($2)}')" = "running" ]; then if [ "$(supervisorctl status worker:worker_0 | awk '{print tolower($2)}')" = "running" ]; then
exit 0 exit 0

View File

@@ -3,14 +3,6 @@
admin {$CADDY_SERVER_ADMIN_HOST}:{$CADDY_SERVER_ADMIN_PORT} admin {$CADDY_SERVER_ADMIN_HOST}:{$CADDY_SERVER_ADMIN_PORT}
log {
level {$CADDY_SERVER_LOG_LEVEL:WARN}
}
auto_https off
skip_install_trust
frankenphp { frankenphp {
worker "{$APP_PUBLIC_PATH}/frankenphp-worker.php" {$CADDY_SERVER_WORKER_COUNT} worker "{$APP_PUBLIC_PATH}/frankenphp-worker.php" {$CADDY_SERVER_WORKER_COUNT}
} }
@@ -28,7 +20,7 @@
{$CADDY_SERVER_SERVER_NAME} { {$CADDY_SERVER_SERVER_NAME} {
log { log {
level {$CADDY_SERVER_LOG_LEVEL:WARN} level WARN
format filter { format filter {
wrap {$CADDY_SERVER_LOGGER} wrap {$CADDY_SERVER_LOGGER}
@@ -68,6 +60,7 @@
error @rejected 401 error @rejected 401
php_server { php_server {
index frankenphp-worker.php
try_files {path} frankenphp-worker.php try_files {path} frankenphp-worker.php
resolve_root_symlink resolve_root_symlink
} }

View File

@@ -1,18 +1,65 @@
[program:octane] [program:octane]
process_name = %(program_name)s_%(process_num)s process_name = %(program_name)s_%(process_num)s
command = php %(ENV_ROOT)s/artisan octane:frankenphp --host=0.0.0.0 --port=8000 --admin-host=0.0.0.0 --admin-port=2019 --log-level=WARN --caddyfile=%(ENV_ROOT)s/docker/prod/deployment/octane/FrankenPHP/Caddyfile command = php %(ENV_ROOT)s/artisan octane:frankenphp --host=0.0.0.0 --port=8000 --admin-port=2019 --caddyfile=%(ENV_ROOT)s/docker/prod/deployment/octane/FrankenPHP/Caddyfile
user = %(ENV_USER)s user = %(ENV_USER)s
priority = 1 priority = 1
autostart = true autostart = true
autorestart = true autorestart = true
stopwaitsecs = 30
stopasgroup = true
killasgroup = true
environment = LARAVEL_OCTANE = "1" environment = LARAVEL_OCTANE = "1"
stdout_logfile = /dev/stdout stdout_logfile = /dev/stdout
stdout_logfile_maxbytes = 0 stdout_logfile_maxbytes = 0
stderr_logfile = /dev/stderr stderr_logfile = /dev/stderr
stderr_logfile_maxbytes = 0 stderr_logfile_maxbytes = 0
[program:horizon]
process_name = %(program_name)s_%(process_num)s
command = php %(ENV_ROOT)s/artisan horizon
user = %(ENV_USER)s
priority = 3
autostart = %(ENV_WITH_HORIZON)s
autorestart = true
stdout_logfile = %(ENV_ROOT)s/storage/logs/horizon.log
stdout_logfile_maxbytes = 200MB
stderr_logfile = %(ENV_ROOT)s/storage/logs/horizon.log
stderr_logfile_maxbytes = 200MB
stopwaitsecs = 3600
[program:scheduler]
process_name = %(program_name)s_%(process_num)s
command = supercronic -overlapping /etc/supercronic/laravel
user = %(ENV_USER)s
autostart = %(ENV_WITH_SCHEDULER)s
autorestart = true
stdout_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
stdout_logfile_maxbytes = 200MB
stderr_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
stderr_logfile_maxbytes = 200MB
[program:clear-scheduler-cache]
process_name = %(program_name)s_%(process_num)s
command = php %(ENV_ROOT)s/artisan schedule:clear-cache
user = %(ENV_USER)s
autostart = %(ENV_WITH_SCHEDULER)s
autorestart = false
startsecs = 0
startretries = 1
stdout_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
stdout_logfile_maxbytes = 200MB
stderr_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
stderr_logfile_maxbytes = 200MB
[program:reverb]
process_name = %(program_name)s_%(process_num)s
command = php %(ENV_ROOT)s/artisan reverb:start
user = %(ENV_USER)s
priority = 2
autostart = %(ENV_WITH_REVERB)s
autorestart = true
stdout_logfile = %(ENV_ROOT)s/storage/logs/reverb.log
stdout_logfile_maxbytes = 200MB
stderr_logfile = %(ENV_ROOT)s/storage/logs/reverb.log
stderr_logfile_maxbytes = 200MB
minfds = 10000
[include] [include]
files = /etc/supervisord.conf /etc/supervisor/conf.d/supervisord.services.conf files = /etc/supervisord.conf

View File

@@ -2,9 +2,8 @@
post_max_size = 100M post_max_size = 100M
upload_max_filesize = 100M upload_max_filesize = 100M
expose_php = 0 expose_php = 0
realpath_cache_size = 32M realpath_cache_size = 16M
realpath_cache_ttl = 720 realpath_cache_ttl = 360
memory_limit = 256M
max_input_time = 5 max_input_time = 5
register_argc_argv = 0 register_argc_argv = 0
date.timezone = ${TZ:-UTC} date.timezone = ${TZ:-UTC}
@@ -12,24 +11,18 @@ date.timezone = ${TZ:-UTC}
[Opcache] [Opcache]
opcache.enable = 1 opcache.enable = 1
opcache.enable_cli = 1 opcache.enable_cli = 1
opcache.memory_consumption = 256 opcache.memory_consumption = 256M
opcache.use_cwd = 0 opcache.use_cwd = 0
opcache.save_comments = 1
opcache.max_file_size = 0 opcache.max_file_size = 0
opcache.max_accelerated_files = 32531 opcache.max_accelerated_files = 32531
opcache.validate_timestamps = 0 opcache.validate_timestamps = 0
opcache.file_update_protection = 0 opcache.file_update_protection = 0
opcache.interned_strings_buffer = 16 opcache.interned_strings_buffer = 16
opcache.enable_file_override = 1
opcache.file_cache_consistency_checks = 0
opcache.file_cache = /tmp/opcache-file-cache
[JIT] [JIT]
opcache.jit_buffer_size = 128M opcache.jit_buffer_size = 128M
opcache.jit = tracing opcache.jit = function
opcache.jit_hot_loop = 16 opcache.jit_prof_threshold = 0.001
opcache.jit_hot_func = 32
opcache.jit_hot_return = 4
opcache.jit_max_root_traces = 2048 opcache.jit_max_root_traces = 2048
opcache.jit_max_side_traces = 256 opcache.jit_max_side_traces = 256

View File

@@ -1,6 +1,8 @@
[supervisord] [supervisord]
nodaemon = true nodaemon = true
user = %(ENV_USER)s user = %(ENV_USER)s
logfile = /var/log/supervisor/supervisord.log
pidfile = /var/run/supervisord.pid
[supervisorctl] [supervisorctl]
@@ -8,4 +10,4 @@ user = %(ENV_USER)s
port = 127.0.0.1:9001 port = 127.0.0.1:9001
[rpcinterface:supervisor] [rpcinterface:supervisor]
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface

View File

@@ -4,8 +4,6 @@ command = php %(ENV_ROOT)s/artisan horizon
user = %(ENV_USER)s user = %(ENV_USER)s
autostart = true autostart = true
autorestart = true autorestart = true
stopasgroup = true
killasgroup = true
stdout_logfile = /dev/stdout stdout_logfile = /dev/stdout
stdout_logfile_maxbytes = 0 stdout_logfile_maxbytes = 0
stderr_logfile = /dev/stderr stderr_logfile = /dev/stderr

View File

@@ -1,15 +0,0 @@
[program:inertia-ssr-server]
process_name = %(program_name)s_%(process_num)s
command = php %(ENV_ROOT)s/artisan inertia:start-ssr --runtime=bun --quiet
user = %(ENV_USER)s
autostart = true
autorestart = true
stopasgroup = true
killasgroup = true
stdout_logfile = /dev/stdout
stdout_logfile_maxbytes = 0
stderr_logfile = /dev/stderr
stderr_logfile_maxbytes = 0
[include]
files = /etc/supervisord.conf

View File

@@ -4,8 +4,6 @@ command = php %(ENV_ROOT)s/artisan reverb:start
user = %(ENV_USER)s user = %(ENV_USER)s
autostart = true autostart = true
autorestart = true autorestart = true
stopasgroup = true
killasgroup = true
stdout_logfile = /dev/stdout stdout_logfile = /dev/stdout
stdout_logfile_maxbytes = 0 stdout_logfile_maxbytes = 0
stderr_logfile = /dev/stderr stderr_logfile = /dev/stderr

View File

@@ -4,8 +4,6 @@ command = supercronic -overlapping /etc/supercronic/laravel
user = %(ENV_USER)s user = %(ENV_USER)s
autostart = true autostart = true
autorestart = true autorestart = true
stopasgroup = true
killasgroup = true
stdout_logfile = /dev/stdout stdout_logfile = /dev/stdout
stdout_logfile_maxbytes = 0 stdout_logfile_maxbytes = 0
stderr_logfile = /dev/stderr stderr_logfile = /dev/stderr

View File

@@ -1,69 +0,0 @@
[program:horizon]
process_name = %(program_name)s_%(process_num)s
command = php %(ENV_ROOT)s/artisan horizon
user = %(ENV_USER)s
priority = 4
autostart = %(ENV_WITH_HORIZON)s
autorestart = true
stopasgroup = true
killasgroup = true
stdout_logfile = %(ENV_ROOT)s/storage/logs/horizon.log
stdout_logfile_maxbytes = 200MB
stderr_logfile = %(ENV_ROOT)s/storage/logs/horizon.log
stderr_logfile_maxbytes = 200MB
stopwaitsecs = 3600
[program:scheduler]
process_name = %(program_name)s_%(process_num)s
command = supercronic -overlapping /etc/supercronic/laravel
user = %(ENV_USER)s
autostart = %(ENV_WITH_SCHEDULER)s
autorestart = true
stopasgroup = true
killasgroup = true
stdout_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
stdout_logfile_maxbytes = 200MB
stderr_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
stderr_logfile_maxbytes = 200MB
[program:clear-scheduler-cache]
process_name = %(program_name)s_%(process_num)s
command = php %(ENV_ROOT)s/artisan schedule:clear-cache
user = %(ENV_USER)s
autostart = %(ENV_WITH_SCHEDULER)s
autorestart = false
startsecs = 0
startretries = 1
stdout_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
stdout_logfile_maxbytes = 200MB
stderr_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
stderr_logfile_maxbytes = 200MB
[program:reverb]
process_name = %(program_name)s_%(process_num)s
command = php %(ENV_ROOT)s/artisan reverb:start
user = %(ENV_USER)s
priority = 3
autostart = %(ENV_WITH_REVERB)s
autorestart = true
stopasgroup = true
killasgroup = true
stdout_logfile = %(ENV_ROOT)s/storage/logs/reverb.log
stdout_logfile_maxbytes = 200MB
stderr_logfile = %(ENV_ROOT)s/storage/logs/reverb.log
stderr_logfile_maxbytes = 200MB
minfds = 10000
[program:inertia-ssr-server]
process_name = %(program_name)s_%(process_num)s
command = php %(ENV_ROOT)s/artisan inertia:start-ssr --runtime=bun --quiet
user = %(ENV_USER)s
priority = 2
autostart = %(ENV_WITH_SSR)s
autorestart = true
stopasgroup = true
killasgroup = true
stdout_logfile = %(ENV_ROOT)s/storage/logs/inertia-ssr-server.log
stdout_logfile_maxbytes = 200MB
stderr_logfile = %(ENV_ROOT)s/storage/logs/inertia-ssr-server.log
stderr_logfile_maxbytes = 200MB

View File

@@ -4,8 +4,6 @@ command = %(ENV_WORKER_COMMAND)s
user = %(ENV_USER)s user = %(ENV_USER)s
autostart = true autostart = true
autorestart = true autorestart = true
stopasgroup = true
killasgroup = true
stdout_logfile = /dev/stdout stdout_logfile = /dev/stdout
stdout_logfile_maxbytes = 0 stdout_logfile_maxbytes = 0
stderr_logfile = /dev/stderr stderr_logfile = /dev/stderr

View File

@@ -53,6 +53,47 @@ function todayAt(hour: number, minute: number = 0): string {
return d.toISOString().replace(/\.\d{3}Z$/, 'Z'); return d.toISOString().replace(/\.\d{3}Z$/, 'Z');
} }
// Cross-day tests anchor to Wednesday and Thursday of the current week instead of today and
// tomorrow. E2E users start the week on Monday, so both days are always in the visible week,
// whichever day the suite runs on.
function wednesdayAt(hour: number, minute: number = 0): Date {
const now = new Date();
const daysSinceMonday = (now.getDay() + 6) % 7;
return new Date(
now.getFullYear(),
now.getMonth(),
now.getDate() - daysSinceMonday + 2,
hour,
minute,
0,
0
);
}
function thursdayAt(hour: number, minute: number = 0): Date {
const d = wednesdayAt(hour, minute);
d.setDate(d.getDate() + 1);
return d;
}
function toApiTimestamp(date: Date): string {
return date.toISOString().replace(/\.\d{3}Z$/, 'Z');
}
// Local calendar date (YYYY-MM-DD), matching FullCalendar's data-date attributes
function toDateStr(date: Date): string {
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${date.getFullYear()}-${month}-${day}`;
}
async function columnHeaderCenterX(page: Page, date: Date): Promise<number> {
const header = page.locator(`.fc-col-header-cell[data-date="${toDateStr(date)}"]`);
await expect(header).toBeVisible();
const box = await header.boundingBox();
return box!.x + box!.width / 2;
}
/** /**
* These tests verify that changing the project on a time entry via the calendar * These tests verify that changing the project on a time entry via the calendar
* updates the billable status to match the new project's is_billable setting. * updates the billable status to match the new project's is_billable setting.
@@ -252,19 +293,19 @@ test('test that calendar page loads and displays time entries', async ({ page, c
test('test that calendar navigation buttons work', async ({ page }) => { test('test that calendar navigation buttons work', async ({ page }) => {
await goToCalendar(page); await goToCalendar(page);
await expect(page.locator('.fc')).toBeVisible(); await expect(page.locator('.fc')).toBeVisible();
await expect(page.getByTestId('calendar-title')).toContainText('This Week');
// Navigate between named periods. // Click the "next" button to navigate forward
await page.getByRole('button', { name: 'Next' }).click();
await expect(page.locator('.fc')).toBeVisible();
// Click the "prev" button to navigate back
await page.getByRole('button', { name: 'Previous' }).click(); await page.getByRole('button', { name: 'Previous' }).click();
await expect(page.getByTestId('calendar-title')).toContainText('Last Week'); await expect(page.locator('.fc')).toBeVisible();
await page.getByRole('button', { name: 'Next' }).click();
await expect(page.getByTestId('calendar-title')).toContainText('This Week');
// Navigate forward first, then use the range button to return to today. // Navigate forward first, then click today
await page.getByRole('button', { name: 'Next' }).click(); await page.getByRole('button', { name: 'Next' }).click();
await expect(page.getByTestId('calendar-title')).toContainText('Next Week');
await page.getByRole('button', { name: 'today' }).click(); await page.getByRole('button', { name: 'today' }).click();
await expect(page.getByTestId('calendar-title')).toContainText('This Week'); await expect(page.locator('.fc')).toBeVisible();
}); });
test('test that editing time entry description via calendar modal works', async ({ page, ctx }) => { test('test that editing time entry description via calendar modal works', async ({ page, ctx }) => {
@@ -962,14 +1003,9 @@ test.describe('Drag-to-Move Events', () => {
page, page,
ctx, ctx,
}) => { }) => {
const now = new Date(); // Create entry: Thursday 00:30 → Thursday 01:30 (1 hour, near midnight)
const dayOfWeek = now.getDay(); const start = toApiTimestamp(thursdayAt(0, 30));
// Need today to have a previous day visible in the week view (skip Sunday with Monday week start) const end = toApiTimestamp(thursdayAt(1, 30));
test.skip(dayOfWeek === 1, 'Skipping on Monday — previous day not visible in week view');
// Create entry: today 00:30 → today 01:30 (1 hour, near midnight)
const start = todayAt(0, 30);
const end = todayAt(1, 30);
await createTimeEntryWithTimestampsViaApi(ctx, { await createTimeEntryWithTimestampsViaApi(ctx, {
description: 'Drag up past midnight test', description: 'Drag up past midnight test',
start, start,
@@ -978,11 +1014,10 @@ test.describe('Drag-to-Move Events', () => {
await goToCalendar(page); await goToCalendar(page);
await scrollCalendarToTime(page, '00:00:00'); await scrollCalendarToTime(page, '00:00:00');
const todayStr = new Date(now.getFullYear(), now.getMonth(), now.getDate()) const thursdayCol = page.locator(
.toISOString() `.fc-timegrid-col[data-date="${toDateStr(thursdayAt(0))}"]`
.split('T')[0]; );
const todayCol = page.locator(`.fc-timegrid-col[data-date="${todayStr}"]`); const event = thursdayCol
const event = todayCol
.locator('.fc-event') .locator('.fc-event')
.filter({ hasText: 'Drag up past midnight test' }); .filter({ hasText: 'Drag up past midnight test' });
await expect(event).toBeVisible({ timeout: 10000 }); await expect(event).toBeVisible({ timeout: 10000 });
@@ -1018,10 +1053,7 @@ test.describe('Drag-to-Move Events', () => {
expect(newDurationMs).toBe(3600000); expect(newDurationMs).toBe(3600000);
// The event should have moved to the previous day // The event should have moved to the previous day
const yesterdayStr = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1) expect(toDateStr(newStart)).toBe(toDateStr(wednesdayAt(0)));
.toISOString()
.split('T')[0];
expect(newStart.toISOString().split('T')[0]).toBe(yesterdayStr);
}); });
}); });
@@ -1261,8 +1293,8 @@ test.describe('Resize Events', () => {
}); });
test('resize bottom edge across day boundary changes end date', async ({ page, ctx }) => { test('resize bottom edge across day boundary changes end date', async ({ page, ctx }) => {
const start = todayAt(10); const start = toApiTimestamp(wednesdayAt(10));
const end = todayAt(11); const end = toApiTimestamp(wednesdayAt(11));
await createTimeEntryWithTimestampsViaApi(ctx, { await createTimeEntryWithTimestampsViaApi(ctx, {
description: 'Cross resize end test', description: 'Cross resize end test',
start, start,
@@ -1271,26 +1303,8 @@ test.describe('Resize Events', () => {
await goToCalendar(page); await goToCalendar(page);
await scrollCalendarToTime(page, '09:00:00'); await scrollCalendarToTime(page, '09:00:00');
// Find a column AFTER today (for end resize, end must be > start) // Resize into Thursday's column (for end resize, end must be > start)
const headers = page.locator('.fc-col-header-cell'); const targetX = await columnHeaderCenterX(page, thursdayAt(0));
const headerCount = await headers.count();
let targetX: number | undefined;
let todayIndex = -1;
for (let i = 0; i < headerCount; i++) {
const header = headers.nth(i);
const isToday = await header.evaluate((el) => el.classList.contains('fc-day-today'));
if (isToday) {
todayIndex = i;
break;
}
}
// Pick first column after today, or skip if today is last
for (let i = todayIndex + 1; i < headerCount; i++) {
const box = await headers.nth(i).boundingBox();
targetX = box!.x + box!.width / 2;
break;
}
test.skip(targetX === undefined, 'No column after today to resize to');
const event = page const event = page
.locator('.fc-event') .locator('.fc-event')
@@ -1318,7 +1332,7 @@ test.describe('Resize Events', () => {
// First drag down vertically to engage resize (like test 3.1) // First drag down vertically to engage resize (like test 3.1)
await page.mouse.move(centerX, bottomY + slotHeight * 4, { steps: 15 }); await page.mouse.move(centerX, bottomY + slotHeight * 4, { steps: 15 });
// Then move horizontally to a later day column // Then move horizontally to a later day column
await page.mouse.move(targetX!, bottomY + slotHeight * 4, { steps: 10 }); await page.mouse.move(targetX, bottomY + slotHeight * 4, { steps: 10 });
await page.mouse.up(); await page.mouse.up();
})(), })(),
]); ]);
@@ -1340,8 +1354,8 @@ test.describe('Resize Events', () => {
page, page,
ctx, ctx,
}) => { }) => {
const start = todayAt(10); const start = toApiTimestamp(wednesdayAt(10));
const end = todayAt(14); const end = toApiTimestamp(wednesdayAt(14));
await createTimeEntryWithTimestampsViaApi(ctx, { await createTimeEntryWithTimestampsViaApi(ctx, {
description: 'Cross resize end test', description: 'Cross resize end test',
start, start,
@@ -1350,30 +1364,8 @@ test.describe('Resize Events', () => {
await goToCalendar(page); await goToCalendar(page);
await scrollCalendarToTime(page, '09:00:00'); await scrollCalendarToTime(page, '09:00:00');
// Find a non-today column header that is AFTER today (later day needed for end-edge resize) // Resize into Thursday's column (later day needed for end-edge resize)
const headers = page.locator('.fc-col-header-cell'); const targetX = await columnHeaderCenterX(page, thursdayAt(0));
const headerCount = await headers.count();
let targetX: number | undefined;
let foundToday = false;
for (let i = 0; i < headerCount; i++) {
const header = headers.nth(i);
const isToday = await header.evaluate((el) => el.classList.contains('fc-day-today'));
if (isToday) {
foundToday = true;
continue;
}
if (foundToday) {
const box = await header.boundingBox();
targetX = box!.x + box!.width / 2;
break;
}
}
// If today is the last column, use the one before today instead won't work for end resize,
// so skip this test in that edge case
if (targetX === undefined) {
test.skip();
return;
}
const event = page const event = page
.locator('.fc-event') .locator('.fc-event')
@@ -1398,7 +1390,7 @@ test.describe('Resize Events', () => {
await page.waitForTimeout(100); await page.waitForTimeout(100);
await page.mouse.down(); await page.mouse.down();
// Move to a different day column at same Y position // Move to a different day column at same Y position
await page.mouse.move(targetX!, bottomY - 3, { steps: 15 }); await page.mouse.move(targetX, bottomY - 3, { steps: 15 });
await page.mouse.up(); await page.mouse.up();
})(), })(),
]); ]);
@@ -1464,22 +1456,9 @@ test.describe('Resize Events', () => {
}); });
test('multi-day event end resize on last day works correctly', async ({ page, ctx }) => { test('multi-day event end resize on last day works correctly', async ({ page, ctx }) => {
// Create entry spanning today evening → tomorrow morning // Create entry spanning Wednesday evening → Thursday morning
const start = todayAt(20); const start = toApiTimestamp(wednesdayAt(20));
const tomorrow = new Date(); const end = toApiTimestamp(thursdayAt(10));
tomorrow.setDate(tomorrow.getDate() + 1);
const tomorrowStr = `${tomorrow.getFullYear()}-${String(tomorrow.getMonth() + 1).padStart(2, '0')}-${String(tomorrow.getDate()).padStart(2, '0')}`;
const end = new Date(
tomorrow.getFullYear(),
tomorrow.getMonth(),
tomorrow.getDate(),
10,
0,
0,
0
)
.toISOString()
.replace(/\.\d{3}Z$/, 'Z');
await createTimeEntryWithTimestampsViaApi(ctx, { await createTimeEntryWithTimestampsViaApi(ctx, {
description: 'Multi-day end resize', description: 'Multi-day end resize',
@@ -1488,14 +1467,14 @@ test.describe('Resize Events', () => {
}); });
await goToCalendar(page); await goToCalendar(page);
// Check if tomorrow column is visible const thursdayCol = page.locator(
const tomorrowCol = page.locator(`.fc-timegrid-col[data-date="${tomorrowStr}"]`); `.fc-timegrid-col[data-date="${toDateStr(thursdayAt(0))}"]`
test.skip((await tomorrowCol.count()) === 0, 'Tomorrow not visible in current view'); );
await scrollCalendarToTime(page, '09:00:00'); await scrollCalendarToTime(page, '09:00:00');
// Find the event segment on tomorrow's column // Find the event segment on Thursday's column
const event = tomorrowCol const event = thursdayCol
.locator('.fc-event') .locator('.fc-event')
.filter({ hasText: 'Multi-day end resize' }) .filter({ hasText: 'Multi-day end resize' })
.first(); .first();
@@ -1541,22 +1520,9 @@ test.describe('Resize Events', () => {
page, page,
ctx, ctx,
}) => { }) => {
// Create entry spanning today → tomorrow // Create entry spanning Wednesday → Thursday
const start = todayAt(10); const start = toApiTimestamp(wednesdayAt(10));
const tomorrow = new Date(); const end = toApiTimestamp(thursdayAt(14));
tomorrow.setDate(tomorrow.getDate() + 1);
const tomorrowStr = `${tomorrow.getFullYear()}-${String(tomorrow.getMonth() + 1).padStart(2, '0')}-${String(tomorrow.getDate()).padStart(2, '0')}`;
const end = new Date(
tomorrow.getFullYear(),
tomorrow.getMonth(),
tomorrow.getDate(),
14,
0,
0,
0
)
.toISOString()
.replace(/\.\d{3}Z$/, 'Z');
await createTimeEntryWithTimestampsViaApi(ctx, { await createTimeEntryWithTimestampsViaApi(ctx, {
description: 'Backward end resize multi', description: 'Backward end resize multi',
@@ -1565,29 +1531,16 @@ test.describe('Resize Events', () => {
}); });
await goToCalendar(page); await goToCalendar(page);
// Check if tomorrow column is visible const thursdayCol = page.locator(
const tomorrowCol = page.locator(`.fc-timegrid-col[data-date="${tomorrowStr}"]`); `.fc-timegrid-col[data-date="${toDateStr(thursdayAt(0))}"]`
test.skip((await tomorrowCol.count()) === 0, 'Tomorrow not visible in current view'); );
await scrollCalendarToTime(page, '12:00:00'); await scrollCalendarToTime(page, '12:00:00');
// Find today's column header to get its X center const wednesdayX = await columnHeaderCenterX(page, wednesdayAt(0));
const headers = page.locator('.fc-col-header-cell');
const headerCount = await headers.count();
let todayX: number | undefined;
for (let i = 0; i < headerCount; i++) {
const header = headers.nth(i);
const isToday = await header.evaluate((el) => el.classList.contains('fc-day-today'));
if (isToday) {
const box = await header.boundingBox();
todayX = box!.x + box!.width / 2;
break;
}
}
test.skip(todayX === undefined, 'Could not find today column header');
// Find event segment on tomorrow's column and resize end backward to today // Find event segment on Thursday's column and resize end backward to Wednesday
const event = tomorrowCol const event = thursdayCol
.locator('.fc-event') .locator('.fc-event')
.filter({ hasText: 'Backward end resize multi' }) .filter({ hasText: 'Backward end resize multi' })
.first(); .first();
@@ -1609,9 +1562,9 @@ test.describe('Resize Events', () => {
await page.mouse.move(centerX, bottomY - 3); await page.mouse.move(centerX, bottomY - 3);
await page.waitForTimeout(100); await page.waitForTimeout(100);
await page.mouse.down(); await page.mouse.down();
// Drag down a bit first, then move to today's column at a Y after the start // Drag down a bit first, then move to Wednesday's column at a Y after the start
await page.mouse.move(centerX, bottomY + slotHeight, { steps: 5 }); await page.mouse.move(centerX, bottomY + slotHeight, { steps: 5 });
await page.mouse.move(todayX!, bottomY + slotHeight, { steps: 10 }); await page.mouse.move(wednesdayX, bottomY + slotHeight, { steps: 10 });
await page.mouse.up(); await page.mouse.up();
})(), })(),
]); ]);
@@ -1627,8 +1580,8 @@ test.describe('Resize Events', () => {
}); });
test('resize end to earlier column prevents end before start', async ({ page, ctx }) => { test('resize end to earlier column prevents end before start', async ({ page, ctx }) => {
const start = todayAt(10); const start = toApiTimestamp(thursdayAt(10));
const end = todayAt(14); const end = toApiTimestamp(thursdayAt(14));
await createTimeEntryWithTimestampsViaApi(ctx, { await createTimeEntryWithTimestampsViaApi(ctx, {
description: 'End before start test', description: 'End before start test',
start, start,
@@ -1637,25 +1590,8 @@ test.describe('Resize Events', () => {
await goToCalendar(page); await goToCalendar(page);
await scrollCalendarToTime(page, '09:00:00'); await scrollCalendarToTime(page, '09:00:00');
// Find a column BEFORE today // Target Wednesday's column, the day before the entry
const headers = page.locator('.fc-col-header-cell'); const targetX = await columnHeaderCenterX(page, wednesdayAt(0));
const headerCount = await headers.count();
let targetX: number | undefined;
let todayIndex = -1;
for (let i = 0; i < headerCount; i++) {
const header = headers.nth(i);
const isToday = await header.evaluate((el) => el.classList.contains('fc-day-today'));
if (isToday) {
todayIndex = i;
break;
}
}
for (let i = todayIndex - 1; i >= 0; i--) {
const box = await headers.nth(i).boundingBox();
targetX = box!.x + box!.width / 2;
break;
}
test.skip(targetX === undefined, 'No column before today to test');
const event = page const event = page
.locator('.fc-event') .locator('.fc-event')
@@ -1683,7 +1619,7 @@ test.describe('Resize Events', () => {
await page.waitForTimeout(100); await page.waitForTimeout(100);
await page.mouse.down(); await page.mouse.down();
// Move to earlier column at a Y position near the top of the grid (before start time) // Move to earlier column at a Y position near the top of the grid (before start time)
await page.mouse.move(targetX!, eventBox!.y - slotHeight * 4, { steps: 15 }); await page.mouse.move(targetX, eventBox!.y - slotHeight * 4, { steps: 15 });
await page.mouse.up(); await page.mouse.up();
// Wait for any potential API call // Wait for any potential API call
@@ -1732,30 +1668,21 @@ test.describe('Click-Drag Selection to Create', () => {
test('drag-to-create spanning two days opens create modal with correct cross-day times', async ({ test('drag-to-create spanning two days opens create modal with correct cross-day times', async ({
page, page,
}) => { }) => {
const now = new Date();
const dayOfWeek = now.getDay();
// Need today and tomorrow both visible (skip Saturday with Monday week start)
test.skip(dayOfWeek === 6, 'Skipping on Saturday — tomorrow not visible in week view');
await goToCalendar(page); await goToCalendar(page);
await expect(page.locator('.fc')).toBeVisible(); await expect(page.locator('.fc')).toBeVisible();
// Use mid-day times so both start and end slots are visible in the viewport // Use mid-day times so both start and end slots are visible in the viewport
await scrollCalendarToTime(page, '10:00:00'); await scrollCalendarToTime(page, '10:00:00');
// Find today's and tomorrow's columns // Find Wednesday's and Thursday's columns
const todayStr = new Date(now.getFullYear(), now.getMonth(), now.getDate()) const wednesdayStr = toDateStr(wednesdayAt(0));
.toISOString() const thursdayStr = toDateStr(thursdayAt(0));
.split('T')[0];
const tomorrowStr = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1)
.toISOString()
.split('T')[0];
const todayCol = page.locator(`.fc-timegrid-col[data-date="${todayStr}"]`); const wednesdayCol = page.locator(`.fc-timegrid-col[data-date="${wednesdayStr}"]`);
const tomorrowCol = page.locator(`.fc-timegrid-col[data-date="${tomorrowStr}"]`); const thursdayCol = page.locator(`.fc-timegrid-col[data-date="${thursdayStr}"]`);
await expect(todayCol).toBeVisible(); await expect(wednesdayCol).toBeVisible();
await expect(tomorrowCol).toBeVisible(); await expect(thursdayCol).toBeVisible();
// Find the 11:00 slot (start) and 13:00 slot (end on tomorrow) // Find the 11:00 slot (start) and 13:00 slot (end on Thursday)
const startSlot = page.locator('.fc-timegrid-slot-lane[data-time="11:00:00"]').first(); const startSlot = page.locator('.fc-timegrid-slot-lane[data-time="11:00:00"]').first();
const endSlot = page.locator('.fc-timegrid-slot-lane[data-time="13:00:00"]').first(); const endSlot = page.locator('.fc-timegrid-slot-lane[data-time="13:00:00"]').first();
await expect(startSlot).toBeVisible(); await expect(startSlot).toBeVisible();
@@ -1763,18 +1690,18 @@ test.describe('Click-Drag Selection to Create', () => {
const startSlotBox = await startSlot.boundingBox(); const startSlotBox = await startSlot.boundingBox();
const endSlotBox = await endSlot.boundingBox(); const endSlotBox = await endSlot.boundingBox();
const todayColBox = await todayCol.boundingBox(); const wednesdayColBox = await wednesdayCol.boundingBox();
const tomorrowColBox = await tomorrowCol.boundingBox(); const thursdayColBox = await thursdayCol.boundingBox();
// Start drag at 11:00 on today's column // Start drag at 11:00 on Wednesday's column
const startX = todayColBox!.x + todayColBox!.width / 2; const startX = wednesdayColBox!.x + wednesdayColBox!.width / 2;
const startY = startSlotBox!.y + 2; const startY = startSlotBox!.y + 2;
// End drag at 13:00 on tomorrow's column // End drag at 13:00 on Thursday's column
const endX = tomorrowColBox!.x + tomorrowColBox!.width / 2; const endX = thursdayColBox!.x + thursdayColBox!.width / 2;
const endY = endSlotBox!.y + 2; const endY = endSlotBox!.y + 2;
// Drag from today to tomorrow — move down first, then across // Drag from Wednesday to Thursday, moving down first, then across
const slotHeight = await getSlotHeight(page); const slotHeight = await getSlotHeight(page);
await page.mouse.move(startX, startY); await page.mouse.move(startX, startY);
await page.mouse.down(); await page.mouse.down();
@@ -1792,9 +1719,9 @@ test.describe('Click-Drag Selection to Create', () => {
await expect(dialog.getByText('Start')).toBeVisible(); await expect(dialog.getByText('Start')).toBeVisible();
await expect(dialog.getByText('End')).toBeVisible(); await expect(dialog.getByText('End')).toBeVisible();
// Start date should be today, end date should be tomorrow // Start date should be Wednesday, end date should be Thursday
await expect(dialog.getByText(todayStr)).toBeVisible(); await expect(dialog.getByText(wednesdayStr)).toBeVisible();
await expect(dialog.getByText(tomorrowStr)).toBeVisible(); await expect(dialog.getByText(thursdayStr)).toBeVisible();
}); });
}); });
@@ -1876,17 +1803,9 @@ test.describe('Timezone & Localization', () => {
test.describe('Multi-Day Events', () => { test.describe('Multi-Day Events', () => {
test('event spanning 2 days renders and is visible', async ({ page, ctx }) => { test('event spanning 2 days renders and is visible', async ({ page, ctx }) => {
// Create entry that spans from today 22:00 to tomorrow 02:00 // Create entry that spans from Wednesday 22:00 to Thursday 02:00
const now = new Date(); const start = toApiTimestamp(wednesdayAt(22));
const dayOfWeek = now.getDay(); const end = toApiTimestamp(thursdayAt(2));
// If today is Saturday (6), the entry would span to next week and may not be visible
test.skip(dayOfWeek === 6, 'Skipping on Saturday — multi-day would span to next week');
const startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 22, 0, 0);
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 2, 0, 0);
const start = startDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
const end = endDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
await createTimeEntryWithTimestampsViaApi(ctx, { await createTimeEntryWithTimestampsViaApi(ctx, {
description: 'Multi day entry', description: 'Multi day entry',
@@ -1901,14 +1820,8 @@ test.describe('Multi-Day Events', () => {
}); });
test('multi-day event can be edited via click', async ({ page, ctx }) => { test('multi-day event can be edited via click', async ({ page, ctx }) => {
const now = new Date(); const start = toApiTimestamp(wednesdayAt(22));
test.skip(now.getDay() === 6, 'Skip on Saturday'); const end = toApiTimestamp(thursdayAt(2));
const startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 22, 0, 0);
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 2, 0, 0);
const start = startDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
const end = endDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
await createTimeEntryWithTimestampsViaApi(ctx, { await createTimeEntryWithTimestampsViaApi(ctx, {
description: 'Multi day edit test', description: 'Multi day edit test',
@@ -1927,14 +1840,8 @@ test.describe('Multi-Day Events', () => {
}); });
test('multi-day event context menu works', async ({ page, ctx }) => { test('multi-day event context menu works', async ({ page, ctx }) => {
const now = new Date(); const start = toApiTimestamp(wednesdayAt(22));
test.skip(now.getDay() === 6, 'Skip on Saturday'); const end = toApiTimestamp(thursdayAt(2));
const startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 22, 0, 0);
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 2, 0, 0);
const start = startDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
const end = endDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
await createTimeEntryWithTimestampsViaApi(ctx, { await createTimeEntryWithTimestampsViaApi(ctx, {
description: 'Multi day ctx test', description: 'Multi day ctx test',
@@ -1954,16 +1861,11 @@ test.describe('Multi-Day Events', () => {
page, page,
ctx, ctx,
}) => { }) => {
const now = new Date(); // Create entry: Wednesday 22:00 → Thursday 02:00 (4 hours, spanning 2 days)
const dayOfWeek = now.getDay(); const startDate = wednesdayAt(22);
// Need today and tomorrow both visible (skip Saturday) const endDate = thursdayAt(2);
test.skip(dayOfWeek === 6, 'Skipping on Saturday — multi-day would span to next week'); const start = toApiTimestamp(startDate);
const end = toApiTimestamp(endDate);
// Create entry: today 22:00 → tomorrow 02:00 (4 hours, spanning 2 days)
const startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 22, 0, 0);
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 2, 0, 0);
const start = startDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
const end = endDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
await createTimeEntryWithTimestampsViaApi(ctx, { await createTimeEntryWithTimestampsViaApi(ctx, {
description: 'Multi day drag test', description: 'Multi day drag test',
@@ -1973,12 +1875,9 @@ test.describe('Multi-Day Events', () => {
await goToCalendar(page); await goToCalendar(page);
await scrollCalendarToTime(page, '00:00:00'); await scrollCalendarToTime(page, '00:00:00');
// Find the clipped segment on tomorrow's column (00:00-02:00) // Find the clipped segment on Thursday's column (00:00-02:00)
const tomorrowStr = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1) const thursdayCol = page.locator(`.fc-timegrid-col[data-date="${toDateStr(endDate)}"]`);
.toISOString() const event = thursdayCol.locator('.fc-event').filter({ hasText: 'Multi day drag test' });
.split('T')[0];
const tomorrowCol = page.locator(`.fc-timegrid-col[data-date="${tomorrowStr}"]`);
const event = tomorrowCol.locator('.fc-event').filter({ hasText: 'Multi day drag test' });
await expect(event).toBeVisible({ timeout: 10000 }); await expect(event).toBeVisible({ timeout: 10000 });
const eventBox = await event.boundingBox(); const eventBox = await event.boundingBox();
@@ -2013,27 +1912,19 @@ test.describe('Multi-Day Events', () => {
// Duration must be preserved (4 hours) // Duration must be preserved (4 hours)
expect(Math.abs(newDurationMs - origDurationMs)).toBeLessThan(60000); expect(Math.abs(newDurationMs - origDurationMs)).toBeLessThan(60000);
// The start should still be on today (not jumped to tomorrow) // The start should still be on Wednesday (not jumped to Thursday)
const todayStr = new Date(now.getFullYear(), now.getMonth(), now.getDate()) expect(toDateStr(newStart)).toBe(toDateStr(startDate));
.toISOString()
.split('T')[0];
expect(newStart.toISOString().split('T')[0]).toBe(todayStr);
}); });
test('dragging clipped segment of multi-day event upward shifts event earlier', async ({ test('dragging clipped segment of multi-day event upward shifts event earlier', async ({
page, page,
ctx, ctx,
}) => { }) => {
const now = new Date(); // Create entry: Wednesday 22:00 → Thursday 02:00 (4 hours, spanning 2 days)
const dayOfWeek = now.getDay(); const startDate = wednesdayAt(22);
// Need today and tomorrow both visible (skip Saturday) const endDate = thursdayAt(2);
test.skip(dayOfWeek === 6, 'Skipping on Saturday — multi-day would span to next week'); const start = toApiTimestamp(startDate);
const end = toApiTimestamp(endDate);
// Create entry: today 22:00 → tomorrow 02:00 (4 hours, spanning 2 days)
const startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 22, 0, 0);
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 2, 0, 0);
const start = startDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
const end = endDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
await createTimeEntryWithTimestampsViaApi(ctx, { await createTimeEntryWithTimestampsViaApi(ctx, {
description: 'Multi day drag up test', description: 'Multi day drag up test',
@@ -2043,12 +1934,9 @@ test.describe('Multi-Day Events', () => {
await goToCalendar(page); await goToCalendar(page);
await scrollCalendarToTime(page, '00:00:00'); await scrollCalendarToTime(page, '00:00:00');
// Find the clipped segment on tomorrow's column (00:00-02:00) // Find the clipped segment on Thursday's column (00:00-02:00)
const tomorrowStr = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1) const thursdayCol = page.locator(`.fc-timegrid-col[data-date="${toDateStr(endDate)}"]`);
.toISOString() const event = thursdayCol
.split('T')[0];
const tomorrowCol = page.locator(`.fc-timegrid-col[data-date="${tomorrowStr}"]`);
const event = tomorrowCol
.locator('.fc-event') .locator('.fc-event')
.filter({ hasText: 'Multi day drag up test' }); .filter({ hasText: 'Multi day drag up test' });
await expect(event).toBeVisible({ timeout: 10000 }); await expect(event).toBeVisible({ timeout: 10000 });

View File

@@ -18,25 +18,6 @@ import {
// Each test registers a new user and creates test data via API // Each test registers a new user and creates test data via API
test.describe.configure({ timeout: 30000 }); test.describe.configure({ timeout: 30000 });
test('date range picker labels and navigates reporting periods', async ({ page }) => {
await goToReporting(page);
const range = page.getByTestId('date_range_picker_display');
const previous = page.getByTestId('date_range_picker_previous');
const next = page.getByTestId('date_range_picker_next');
await expect(range).toContainText('Last 14 Days');
await expect(next).toBeDisabled();
await Promise.all([waitForReportingUpdate(page), previous.click()]);
await expect(range).not.toContainText('Last 14 Days');
// The initial reporting range is still fresh in Vue Query's cache, so returning to it
// does not necessarily trigger another aggregate request.
await next.click();
await expect(range).toContainText('Last 14 Days');
});
// ────────────────────────────────────────────────── // ──────────────────────────────────────────────────
// Project Multiselect Dropdown Tests // Project Multiselect Dropdown Tests
// ────────────────────────────────────────────────── // ──────────────────────────────────────────────────

View File

@@ -1579,57 +1579,6 @@ test('test that project selection works in create modal', async ({ page, ctx })
expect(createBody.data.project_id).not.toBeNull(); expect(createBody.data.project_id).not.toBeNull();
}); });
test('test that ctrl+enter submits the create modal after selecting a project via keyboard', async ({
page,
ctx,
}) => {
// Regression test for https://github.com/solidtime-io/solidtime/issues/1238
const projectName = 'Keyboard Submit Project ' + Math.floor(1 + Math.random() * 10000);
await createProjectViaApi(ctx, { name: projectName });
await goToTimeOverview(page);
await page.getByRole('button', { name: 'Time entry actions' }).click();
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
// The menu that opened the modal animates out and only then hands focus back to its
// own trigger.
await expect(page.locator('[role="menu"]')).toHaveCount(0);
// Fill the description, then move to the project dropdown and select a project purely via keyboard
const description = page.getByRole('dialog').getByRole('textbox', { name: 'Description' });
await description.fill('Keyboard submit test');
await description.press('Tab');
await expect(
page.getByRole('dialog').getByRole('button', { name: 'No Project' })
).toBeFocused();
await page.keyboard.press('Enter');
await page.getByTestId('client_dropdown_search').fill(projectName);
await expect(page.getByRole('option', { name: projectName })).toBeVisible();
await page.keyboard.press('Enter');
const projectTrigger = page.getByRole('dialog').getByRole('button', { name: projectName });
await expect(projectTrigger).toBeVisible();
// The trigger label updates on the next tick, but the dropdown keeps focus until its
// exit animation has finished and reka-ui hands focus back to the trigger.
await expect(projectTrigger).toBeFocused();
// Ctrl+Enter must submit even though focus is no longer on the description input
const [createResponse] = await Promise.all([
page.waitForResponse(
(response) => response.url().includes('/time-entries') && response.status() === 201
),
page.keyboard.press('Control+Enter'),
]);
const createBody = await createResponse.json();
expect(createBody.data.description).toBe('Keyboard submit test');
expect(createBody.data.project_id).not.toBeNull();
await expect(page.getByRole('dialog')).toBeHidden();
const newTimeEntry = page.locator('[data-testid="time_entry_row"]').first();
await expect(newTimeEntry.getByTestId('time_entry_description').first()).toHaveValue(
'Keyboard submit test'
);
});
test('test that tag selection works in create modal', async ({ page }) => { test('test that tag selection works in create modal', async ({ page }) => {
await goToTimeOverview(page); await goToTimeOverview(page);

View File

@@ -409,20 +409,20 @@ test('navigating to previous week shows entries from that week', async ({ page,
test('can navigate forward and return to current week', async ({ page }) => { test('can navigate forward and return to current week', async ({ page }) => {
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]); await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
// Should show "This Week" // Should show "This week"
await expect(page.getByTestId('timesheet_week_display')).toContainText('This Week'); await expect(page.getByTestId('timesheet_week_display')).toContainText('This week');
// Go to next week — the text assertions below auto-retry until the // Go to next week — the text assertions below auto-retry until the
// header label flips. // header label flips.
await page.getByTestId('timesheet_next_week').click(); await page.getByTestId('timesheet_next_week').click();
// Should no longer show "This Week" // Should no longer show "This week"
await expect(page.getByTestId('timesheet_week_display')).not.toContainText('This Week'); await expect(page.getByTestId('timesheet_week_display')).not.toContainText('This week');
// Go back to this week // Go back to this week
await page.getByTestId('timesheet_week_display').click(); await page.getByTestId('timesheet_week_display').click();
await expect(page.getByTestId('timesheet_week_display')).toContainText('This Week'); await expect(page.getByTestId('timesheet_week_display')).toContainText('This week');
}); });
// ────────────────────────────────────────────────── // ──────────────────────────────────────────────────

View File

@@ -1,7 +1,7 @@
{ {
"Billing": { "Billing": {
"repository": "solidtime-io/extension-billing", "repository": "solidtime-io/extension-billing",
"ref": "main" "ref": "v0.0.6"
}, },
"Services": { "Services": {
"repository": "solidtime-io/extension-services", "repository": "solidtime-io/extension-services",

View File

@@ -43,7 +43,5 @@
<env name="NEWSLETTER_URL" value="null"/> <env name="NEWSLETTER_URL" value="null"/>
<env name="PASSPORT_PERSONAL_ACCESS_CLIENT_ID" value="null"/> <env name="PASSPORT_PERSONAL_ACCESS_CLIENT_ID" value="null"/>
<env name="PASSPORT_PERSONAL_ACCESS_CLIENT_SECRET" value="null"/> <env name="PASSPORT_PERSONAL_ACCESS_CLIENT_SECRET" value="null"/>
<env name="PADDLE_API_KEY" value="test_phpunit_paddle_api_key"/>
<env name="PADDLE_SANDBOX" value="true"/>
</php> </php>
</phpunit> </phpunit>

View File

@@ -28,7 +28,7 @@ useFocus(clientNameInput, { initialValue: true });
</script> </script>
<template> <template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit"> <DialogModal closeable :show="show" @close="show = false">
<template #title> <template #title>
<div class="flex space-x-2"> <div class="flex space-x-2">
<span> Create Client </span> <span> Create Client </span>

View File

@@ -30,7 +30,7 @@ useFocus(clientNameInput, { initialValue: true });
</script> </script>
<template> <template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit"> <DialogModal closeable :show="show" @close="show = false">
<template #title> <template #title>
<div class="flex space-x-2"> <div class="flex space-x-2">
<span> Update Client </span> <span> Update Client </span>

View File

@@ -136,7 +136,7 @@ const roleDescription = computed(() => {
v-model:show="showOwnershipTransferConfirmModal" v-model:show="showOwnershipTransferConfirmModal"
:member-name="member.name" :member-name="member.name"
@submit="submit"></MemberOwnershipTransferConfirmModal> @submit="submit"></MemberOwnershipTransferConfirmModal>
<DialogModal closeable :show="show" @close="show = false" @submit="saveWithChecks()"> <DialogModal closeable :show="show" @close="show = false">
<template #title> <template #title>
<div class="flex space-x-2"> <div class="flex space-x-2">
<span> Update Member </span> <span> Update Member </span>

View File

@@ -78,7 +78,7 @@ useFocus(clientNameInput, { initialValue: true });
</script> </script>
<template> <template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit"> <DialogModal closeable :show="show" @close="show = false">
<template #title> <template #title>
<div class="flex space-x-2"> <div class="flex space-x-2">
<span> Invite Member </span> <span> Invite Member </span>

View File

@@ -79,7 +79,7 @@ async function submitBillableRate() {
</script> </script>
<template> <template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit"> <DialogModal closeable :show="show" @close="show = false">
<template #title> <template #title>
<div class="flex space-x-2"> <div class="flex space-x-2">
<span> Edit Project {{ props.originalProject.name }} </span> <span> Edit Project {{ props.originalProject.name }} </span>

View File

@@ -10,13 +10,21 @@ import Pagination from '@/packages/ui/src/Pagination.vue';
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue'; import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
export type SortColumn = export type SortColumn =
'name' | 'client_name' | 'spent_time' | 'progress' | 'billable_rate' | 'status' | 'visibility'; | 'name'
| 'tasks'
| 'client_name'
| 'spent_time'
| 'progress'
| 'billable_rate'
| 'status'
| 'visibility';
export type { SortDirection } from '@/utils/useSortableTable'; export type { SortDirection } from '@/utils/useSortableTable';
import { canCreateProjects } from '@/utils/permissions'; import { canCreateProjects } from '@/utils/permissions';
import type { CreateProjectBody, Project, Client, CreateClientBody } from '@/packages/api/src'; import type { CreateProjectBody, Project, Client, CreateClientBody } from '@/packages/api/src';
import { useProjectsStore } from '@/utils/useProjects'; import { useProjectsStore } from '@/utils/useProjects';
import { useClientsStore } from '@/utils/useClients'; import { useClientsStore } from '@/utils/useClients';
import { useClientsQuery } from '@/utils/useClientsQuery'; import { useClientsQuery } from '@/utils/useClientsQuery';
import { useTasksQuery } from '@/utils/useTasksQuery';
import { getOrganizationCurrencyString } from '@/utils/money'; import { getOrganizationCurrencyString } from '@/utils/money';
import { isAllowedToPerformPremiumAction } from '@/utils/billing'; import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { useOrganizationQuery } from '@/utils/useOrganizationQuery'; import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
@@ -56,6 +64,16 @@ const clientNameMap = computed(() => {
return map; return map;
}); });
const { tasks } = useTasksQuery();
const taskCountMap = computed(() => {
const map = new Map<string, number>();
tasks.value.forEach((task) => {
map.set(task.project_id, (map.get(task.project_id) ?? 0) + 1);
});
return map;
});
// Define column accessors for sorting. // Define column accessors for sorting.
// Numeric columns use sortDescFirst so that the first click (chevron down) sorts highest-first, // Numeric columns use sortDescFirst so that the first click (chevron down) sorts highest-first,
// while text columns default to ascending (A-Z) on first click (chevron down). // while text columns default to ascending (A-Z) on first click (chevron down).
@@ -64,6 +82,11 @@ const columns = computed<SortableColumnDef<Project, SortColumn>[]>(() => [
id: 'name', id: 'name',
accessorFn: (row: Project) => row.name.toLowerCase(), accessorFn: (row: Project) => row.name.toLowerCase(),
}, },
{
id: 'tasks',
sortDescFirst: true,
accessorFn: (row: Project) => taskCountMap.value.get(row.id) ?? 0,
},
{ {
id: 'client_name', id: 'client_name',
accessorFn: (row: Project) => { accessorFn: (row: Project) => {
@@ -161,7 +184,7 @@ async function createClient(client: CreateClientBody): Promise<Client | undefine
} }
const gridTemplate = computed(() => { const gridTemplate = computed(() => {
return `grid-template-columns: minmax(300px, 1fr) minmax(150px, auto) minmax(140px, auto) minmax(130px, auto) ${props.showBillableRate ? 'minmax(130px, auto)' : ''} minmax(120px, auto) minmax(120px, auto) 80px;`; return `grid-template-columns: minmax(300px, 1fr) minmax(100px, auto) minmax(150px, auto) minmax(140px, auto) minmax(130px, auto) ${props.showBillableRate ? 'minmax(130px, auto)' : ''} minmax(120px, auto) minmax(120px, auto) 80px;`;
}); });
</script> </script>

View File

@@ -36,6 +36,9 @@ function handleSort(column: SortColumn) {
@sort="handleSort"> @sort="handleSort">
Name Name
</SortableTableHeaderCell> </SortableTableHeaderCell>
<SortableTableHeaderCell column="tasks" v-bind="sortState" @sort="handleSort">
Tasks
</SortableTableHeaderCell>
<SortableTableHeaderCell column="client_name" v-bind="sortState" @sort="handleSort"> <SortableTableHeaderCell column="client_name" v-bind="sortState" @sort="handleSort">
Client Client
</SortableTableHeaderCell> </SortableTableHeaderCell>

View File

@@ -88,17 +88,19 @@ const showEditProjectModal = ref(false);
<ContextMenuTrigger as-child> <ContextMenuTrigger as-child>
<TableRow :href="route('projects.show', { project: project.id })"> <TableRow :href="route('projects.show', { project: project.id })">
<div <div
class="whitespace-nowrap min-w-0 flex items-center space-x-5 py-4 pr-3 text-sm font-medium text-text-primary pl-2 sm:pl-4 lg:pl-6"> class="whitespace-nowrap min-w-0 flex items-center space-x-3 py-4 pr-3 text-sm font-medium text-text-primary pl-2 sm:pl-4 lg:pl-6">
<div <div
:style="{ :style="{ backgroundColor: project.color }"
backgroundColor: project.color, class="w-2.5 h-2.5 ml-1 rounded-full"></div>
boxShadow: `var(--tw-ring-inset) 0 0 0 calc(4px + var(--tw-ring-offset-width)) ${project.color}30`,
}"
class="w-3 h-3 ml-1 rounded-full"></div>
<span class="overflow-ellipsis overflow-hidden"> <span class="overflow-ellipsis overflow-hidden">
{{ project.name }} {{ project.name }}
</span> </span>
<span class="text-text-secondary"> {{ projectTasksCount }} Tasks </span> </div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-primary">
<span v-if="projectTasksCount">
{{ projectTasksCount }} {{ projectTasksCount === 1 ? 'Task' : 'Tasks' }}
</span>
<span v-else class="text-text-tertiary">--</span>
</div> </div>
<div class="whitespace-nowrap min-w-0 px-3 py-4 text-sm text-text-primary"> <div class="whitespace-nowrap min-w-0 px-3 py-4 text-sm text-text-primary">
<div v-if="project.client_id" class="overflow-ellipsis overflow-hidden"> <div v-if="project.client_id" class="overflow-ellipsis overflow-hidden">

View File

@@ -38,7 +38,7 @@ useFocus(projectNameInput, { initialValue: true });
</script> </script>
<template> <template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit"> <DialogModal closeable :show="show" @close="show = false">
<template #title> <template #title>
<div class="flex space-x-2"> <div class="flex space-x-2">
<span>Add Project Member</span> <span>Add Project Member</span>

View File

@@ -63,7 +63,7 @@ useFocus(projectNameInput, { initialValue: true });
</script> </script>
<template> <template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit"> <DialogModal closeable :show="show" @close="show = false">
<template #title> <template #title>
<div class="flex space-x-2"> <div class="flex space-x-2">
<span>Edit Project Member</span> <span>Edit Project Member</span>

View File

@@ -79,7 +79,7 @@ async function submit() {
</script> </script>
<template> <template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit"> <DialogModal closeable :show="show" @close="show = false">
<template #title> <template #title>
<div class="flex space-x-2"> <div class="flex space-x-2">
<span> Create Report </span> <span> Create Report </span>

View File

@@ -96,7 +96,7 @@ async function submit() {
</script> </script>
<template> <template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit"> <DialogModal closeable :show="show" @close="show = false">
<template #title> <template #title>
<div class="flex space-x-2"> <div class="flex space-x-2">
<span> Create Report </span> <span> Create Report </span>

View File

@@ -57,7 +57,7 @@ const { handleApiRequestNotifications } = useNotificationsStore();
const startDate = useSessionStorage<string>( const startDate = useSessionStorage<string>(
'reporting-start-date', 'reporting-start-date',
getLocalizedDayJs(getDayJsInstance()().format()).subtract(13, 'd').format() getLocalizedDayJs(getDayJsInstance()().format()).subtract(14, 'd').format()
); );
const endDate = useSessionStorage<string>( const endDate = useSessionStorage<string>(
'reporting-end-date', 'reporting-end-date',

View File

@@ -36,7 +36,7 @@ useFocus(tagNameInput, { initialValue: true });
</script> </script>
<template> <template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit"> <DialogModal closeable :show="show" @close="show = false">
<template #title> <template #title>
<div class="flex space-x-2"> <div class="flex space-x-2">
<span> Update Tag </span> <span> Update Tag </span>

View File

@@ -50,7 +50,7 @@ useFocus(taskNameInput, { initialValue: true });
</script> </script>
<template> <template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit"> <DialogModal closeable :show="show" @close="show = false">
<template #title> <template #title>
<div class="flex space-x-2"> <div class="flex space-x-2">
<span> Create Task </span> <span> Create Task </span>

View File

@@ -35,7 +35,7 @@ useFocus(taskNameInput, { initialValue: true });
</script> </script>
<template> <template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit"> <DialogModal closeable :show="show" @close="show = false">
<template #title> <template #title>
<div class="flex space-x-2"> <div class="flex space-x-2">
<span> Update Task </span> <span> Update Task </span>

View File

@@ -179,7 +179,7 @@ async function submit() {
</script> </script>
<template> <template>
<DialogModal closeable :show="show" @close="emit('cancel')" @submit="submit"> <DialogModal closeable :show="show" @close="emit('cancel')">
<template #title> <template #title>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<Coffee class="w-5 h-5 text-text-secondary" /> <Coffee class="w-5 h-5 text-text-secondary" />

View File

@@ -1,9 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import DateRangeNavigator from '@/packages/ui/src/Input/DateRangeNavigator.vue'; import { Button } from '@/packages/ui/src/Buttons';
import { ChevronLeftIcon, ChevronRightIcon, CalendarIcon } from '@heroicons/vue/20/solid';
defineProps<{ defineProps<{
isCurrentWeek: boolean;
weekNumber: number; weekNumber: number;
rangeLabel: string; weekRangeDisplay: string;
weekTotalFormatted: string; weekTotalFormatted: string;
}>(); }>();
@@ -17,16 +19,33 @@ defineEmits<{
<template> <template>
<div class="flex flex-wrap items-center justify-between gap-4 mb-4 px-2 sm:px-4 lg:px-6"> <div class="flex flex-wrap items-center justify-between gap-4 mb-4 px-2 sm:px-4 lg:px-6">
<!-- Left: Week navigation --> <!-- Left: Week navigation -->
<DateRangeNavigator <div class="flex items-center gap-2">
:label="rangeLabel" <Button
:secondary-label="`W${weekNumber}`" variant="outline"
previous-test-id="timesheet_prev_week" size="icon"
trigger-test-id="timesheet_week_display" class="h-8 w-8"
next-test-id="timesheet_next_week" data-testid="timesheet_prev_week"
trigger-aria-label="Return to current week" @click="$emit('previous')">
@previous="$emit('previous')" <ChevronLeftIcon class="h-4 w-4" />
@next="$emit('next')" </Button>
@select="$emit('current')" /> <button
data-testid="timesheet_week_display"
class="flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-text-primary hover:bg-card-background rounded-md transition"
@click="$emit('current')">
<CalendarIcon class="h-4 w-4 text-icon-default" />
<span v-if="isCurrentWeek">This week</span>
<span v-else>{{ weekRangeDisplay }}</span>
<span class="text-text-tertiary">&middot; W{{ weekNumber }}</span>
</button>
<Button
variant="outline"
size="icon"
class="h-8 w-8"
data-testid="timesheet_next_week"
@click="$emit('next')">
<ChevronRightIcon class="h-4 w-4" />
</Button>
</div>
<!-- Right: Week total --> <!-- Right: Week total -->
<div class="flex items-center gap-2.5"> <div class="flex items-center gap-2.5">

View File

@@ -61,7 +61,7 @@ type TimeEntryRoundingType = 'up' | 'down' | 'nearest';
const startDate = useSessionStorage<string>( const startDate = useSessionStorage<string>(
'reporting-start-date', 'reporting-start-date',
getLocalizedDayJs(getDayJsInstance()().format()).subtract(13, 'd').format() getLocalizedDayJs(getDayJsInstance()().format()).subtract(14, 'd').format()
); );
const endDate = useSessionStorage<string>( const endDate = useSessionStorage<string>(
'reporting-end-date', 'reporting-end-date',

View File

@@ -30,7 +30,6 @@ import {
getLocalizedDayJs, getLocalizedDayJs,
} from '@/packages/ui/src/utils/time'; } from '@/packages/ui/src/utils/time';
import { getBreakPlacementHint } from '@/packages/ui/src/utils/breakPlacement'; import { getBreakPlacementHint } from '@/packages/ui/src/utils/breakPlacement';
import { getDateRangeLabel } from '@/packages/ui/src/utils/dateRange';
import { useTimesheetWeek } from '@/utils/timesheet/useTimesheetWeek'; import { useTimesheetWeek } from '@/utils/timesheet/useTimesheetWeek';
import { useTimesheetCellMutations } from '@/utils/timesheet/useTimesheetCellMutations'; import { useTimesheetCellMutations } from '@/utils/timesheet/useTimesheetCellMutations';
import { useTimesheetRowMutations } from '@/utils/timesheet/useTimesheetRowMutations'; import { useTimesheetRowMutations } from '@/utils/timesheet/useTimesheetRowMutations';
@@ -45,6 +44,7 @@ const {
weekEnd, weekEnd,
weekDays, weekDays,
weekNumber, weekNumber,
isCurrentWeek,
todayDate, todayDate,
goToPreviousWeek, goToPreviousWeek,
goToNextWeek, goToNextWeek,
@@ -109,9 +109,13 @@ const weekTotalFormatted = computed(() =>
formatHumanReadableDuration(grandTotal.value, intervalFormat.value, numberFormat.value) formatHumanReadableDuration(grandTotal.value, intervalFormat.value, numberFormat.value)
); );
const weekRangeLabel = computed(() => const weekRangeDisplay = computed(() => {
getDateRangeLabel(weekStart.value, weekStart.value.add(6, 'day')) const start = weekStart.value;
); const end = start.add(6, 'day');
return start.month() === end.month()
? `${start.format('MMM D')} - ${end.format('D')}`
: `${start.format('MMM D')} - ${end.format('MMM D')}`;
});
// ── Cell / row mutation handlers ────────────────────────────────── // ── Cell / row mutation handlers ──────────────────────────────────
const { const {
@@ -204,8 +208,9 @@ async function createTag(name: string): Promise<Tag | undefined> {
<AppLayout title="Timesheet" data-testid="timesheet_view"> <AppLayout title="Timesheet" data-testid="timesheet_view">
<div class="pt-5 lg:pt-8 pb-4 lg:pb-6"> <div class="pt-5 lg:pt-8 pb-4 lg:pb-6">
<TimesheetHeader <TimesheetHeader
:is-current-week="isCurrentWeek"
:week-number="weekNumber" :week-number="weekNumber"
:range-label="weekRangeLabel" :week-range-display="weekRangeDisplay"
:week-total-formatted="weekTotalFormatted" :week-total-formatted="weekTotalFormatted"
@previous="goToPreviousWeek" @previous="goToPreviousWeek"
@next="goToNextWeek" @next="goToNextWeek"

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import Modal from './Modal.vue'; import Modal from './Modal.vue';
const emit = defineEmits(['close', 'submit']); const emit = defineEmits(['close']);
defineProps({ defineProps({
show: { show: {
@@ -24,12 +24,7 @@ const close = () => {
</script> </script>
<template> <template>
<Modal <Modal :show="show" :max-width="maxWidth" :closeable="closeable" @close="close">
:show="show"
:max-width="maxWidth"
:closeable="closeable"
@close="close"
@submit="emit('submit')">
<div class="px-4 lg:px-6 py-4"> <div class="px-4 lg:px-6 py-4">
<div class="text-lg font-medium text-text-primary" role="heading"> <div class="text-lg font-medium text-text-primary" role="heading">
<slot name="title" /> <slot name="title" />

View File

@@ -1,12 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { Button } from '..';
import { ChevronLeft, ChevronRight } from '@lucide/vue';
import { Tabs, TabsList } from '../tabs'; import { Tabs, TabsList } from '../tabs';
import TabBarItem from '../TabBar/TabBarItem.vue'; import TabBarItem from '../TabBar/TabBarItem.vue';
import DateRangeNavigator from '../Input/DateRangeNavigator.vue';
import CalendarSettingsPopover from './CalendarSettingsPopover.vue'; import CalendarSettingsPopover from './CalendarSettingsPopover.vue';
import type { CalendarSettings } from './calendarSettings'; import type { CalendarSettings } from './calendarSettings';
defineProps<{ defineProps<{
rangeLabel: string; viewTitle: string;
activeView: string; activeView: string;
settings: CalendarSettings; settings: CalendarSettings;
}>(); }>();
@@ -22,13 +23,31 @@ const emit = defineEmits<{
<template> <template>
<div class="flex items-center justify-between bg-default-background px-2 py-1.5"> <div class="flex items-center justify-between bg-default-background px-2 py-1.5">
<DateRangeNavigator <!-- Left: Navigation -->
:label="rangeLabel" <div class="flex items-center gap-1">
trigger-test-id="calendar-title" <Button
trigger-aria-label="today" variant="outline"
@previous="emit('prev')" size="sm"
@next="emit('next')" class="h-8 w-8 p-0"
@select="emit('today')" /> aria-label="Previous"
@click="emit('prev')">
<ChevronLeft class="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
class="h-8 w-8 p-0"
aria-label="Next"
@click="emit('next')">
<ChevronRight class="h-4 w-4" />
</Button>
<Button variant="outline" size="sm" @click="emit('today')"> today </Button>
</div>
<!-- Center: Title -->
<span data-testid="calendar-title" class="text-base font-semibold text-foreground">{{
viewTitle
}}</span>
<!-- Right: View switcher + Settings --> <!-- Right: View switcher + Settings -->
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">

View File

@@ -138,7 +138,7 @@ const {
const { const {
activeView, activeView,
viewDays, viewDays,
rangeLabel, viewTitle,
emitDatesChange, emitDatesChange,
handlePrev, handlePrev,
handleNext, handleNext,
@@ -514,7 +514,7 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
<template v-if="!loading"> <template v-if="!loading">
<CalendarToolbar <CalendarToolbar
:range-label="rangeLabel" :view-title="viewTitle"
:active-view="activeView" :active-view="activeView"
:settings="calendarSettings" :settings="calendarSettings"
@prev="handlePrev" @prev="handlePrev"

View File

@@ -2,7 +2,6 @@ import { computed, ref } from 'vue';
import type { Dayjs } from 'dayjs'; import type { Dayjs } from 'dayjs';
import { getLocalizedDayJs } from '../utils/time'; import { getLocalizedDayJs } from '../utils/time';
import { getWeekStartDayNumber } from '../utils/settings'; import { getWeekStartDayNumber } from '../utils/settings';
import { getDateRangeLabel } from '../utils/dateRange';
export function useCalendarNavigation(callbacks: { export function useCalendarNavigation(callbacks: {
onDatesChange: (payload: { start: Dayjs; end: Dayjs }) => void; onDatesChange: (payload: { start: Dayjs; end: Dayjs }) => void;
@@ -35,10 +34,24 @@ export function useCalendarNavigation(callbacks: {
return days; return days;
}); });
const rangeLabel = computed(() => { const viewTitle = computed(() => {
if (activeView.value === 'timeGridDay') {
return currentDate.value.format('MMMM YYYY');
}
const days = viewDays.value; const days = viewDays.value;
if (days.length === 0) return ''; if (days.length === 0) return '';
return getDateRangeLabel(days[0]!, days[days.length - 1]!);
const first = days[0]!;
const last = days[days.length - 1]!;
if (first.year() !== last.year()) {
return `${first.format('MMM YYYY')} \u2013 ${last.format('MMM YYYY')}`;
}
if (first.month() !== last.month()) {
return `${first.format('MMM')} \u2013 ${last.format('MMM YYYY')}`;
}
return first.format('MMMM YYYY');
}); });
function emitDatesChange() { function emitDatesChange() {
@@ -86,7 +99,7 @@ export function useCalendarNavigation(callbacks: {
activeView, activeView,
currentDate, currentDate,
viewDays, viewDays,
rangeLabel, viewTitle,
emitDatesChange, emitDatesChange,
handlePrev, handlePrev,
handleNext, handleNext,

View File

@@ -1,81 +0,0 @@
<script setup lang="ts">
import { ChevronLeft, ChevronRight, CalendarIcon } from '@lucide/vue';
import { Button } from '../Buttons';
withDefaults(
defineProps<{
label: string;
secondaryLabel?: string;
previousDisabled?: boolean;
nextDisabled?: boolean;
previousTestId?: string;
triggerTestId?: string;
nextTestId?: string;
triggerAriaLabel?: string;
}>(),
{
secondaryLabel: undefined,
previousTestId: undefined,
triggerTestId: undefined,
nextTestId: undefined,
triggerAriaLabel: undefined,
}
);
const emit = defineEmits<{
previous: [];
next: [];
select: [];
}>();
const triggerClass =
'h-8 w-[13.5rem] justify-center rounded-none border-l border-r border-border-secondary px-2.5 text-text-primary tabular-nums hover:bg-card-background-active';
</script>
<template>
<div
class="tabular-nums inline-flex items-stretch overflow-hidden rounded-md border border-input bg-card-background shadow-xs dark:bg-transparent"
role="group"
aria-label="Date range navigation">
<Button
type="button"
variant="ghost"
size="icon"
class="h-8 w-8 shrink-0 rounded-none hover:bg-card-background-active"
aria-label="Previous"
:disabled="previousDisabled"
:data-testid="previousTestId"
@click="emit('previous')">
<ChevronLeft class="h-4 w-4" />
</Button>
<slot name="trigger" :trigger-class="triggerClass">
<Button
type="button"
variant="ghost"
size="sm"
:class="triggerClass"
:aria-label="triggerAriaLabel"
:data-testid="triggerTestId"
@click="emit('select')">
<CalendarIcon class="h-4 w-4 shrink-0 text-icon-default" />
<span class="min-w-0 truncate">{{ label }}</span>
<span v-if="secondaryLabel" class="shrink-0 text-text-tertiary">
&middot; {{ secondaryLabel }}
</span>
</Button>
</slot>
<Button
type="button"
variant="ghost"
size="icon"
class="h-8 w-8 shrink-0 rounded-none hover:bg-card-background-active"
aria-label="Next"
:disabled="nextDisabled"
:data-testid="nextTestId"
@click="emit('next')">
<ChevronRight class="h-4 w-4" />
</Button>
</div>
</template>

View File

@@ -6,22 +6,22 @@ import { CalendarDate } from '@internationalized/date';
import { CalendarIcon } from '@lucide/vue'; import { CalendarIcon } from '@lucide/vue';
import { computed, ref, inject, type ComputedRef, watch } from 'vue'; import { computed, ref, inject, type ComputedRef, watch } from 'vue';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
import DateRangeNavigator from './DateRangeNavigator.vue'; import {
import { getLocalizedDayJs, firstDayIndex, type WeekStartDay } from '@/packages/ui/src/utils/time'; getDayJsInstance,
import { getDateRangeLabel, shiftDateRange, type DateRangeDirection } from '../utils/dateRange'; getLocalizedDayJs,
firstDayIndex,
type WeekStartDay,
} from '@/packages/ui/src/utils/time';
import { type Organization } from '@/packages/api/src'; import { type Organization } from '@/packages/api/src';
import { getUserTimezone } from '@/packages/ui/src/utils/settings'; import { getUserTimezone } from '@/packages/ui/src/utils/settings';
import { formatDate } from '@/packages/ui/src/utils/time';
const weekStartsOn = computed((): WeekStartDay => firstDayIndex.value as WeekStartDay); const weekStartsOn = computed((): WeekStartDay => firstDayIndex.value as WeekStartDay);
const props = withDefaults( const props = defineProps<{
defineProps<{ start: string;
start: string; end: string;
end: string; }>();
allowFuture?: boolean;
}>(),
{ allowFuture: false }
);
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'update:start', value: string): void; (e: 'update:start', value: string): void;
@@ -35,13 +35,10 @@ interface CalendarDateRange {
} }
const today = computed(() => { const today = computed(() => {
const now = getLocalizedDayJs(); const now = getDayJsInstance()();
return new CalendarDate(now.year(), now.month() + 1, now.date()); return new CalendarDate(now.year(), now.month() + 1, now.date());
}); });
const startDay = computed(() => (props.start ? getLocalizedDayJs(props.start) : undefined));
const endDay = computed(() => (props.end ? getLocalizedDayJs(props.end) : undefined));
const modelValue = computed<CalendarDateRange>({ const modelValue = computed<CalendarDateRange>({
get: () => ({ get: () => ({
start: props.start start: props.start
@@ -94,7 +91,7 @@ function setLastWeek() {
} }
function setLast14Days() { function setLast14Days() {
emit('update:start', getLocalizedDayJs().subtract(13, 'days').startOf('day').format()); emit('update:start', getLocalizedDayJs().subtract(14, 'days').format());
emit('update:end', getLocalizedDayJs().format()); emit('update:end', getLocalizedDayJs().format());
open.value = false; open.value = false;
} }
@@ -112,14 +109,14 @@ function setLastMonth() {
} }
function setLast30Days() { function setLast30Days() {
emit('update:start', getLocalizedDayJs().subtract(29, 'days').startOf('day').format()); emit('update:start', getLocalizedDayJs().subtract(30, 'days').format());
emit('update:end', getLocalizedDayJs().format()); emit('update:end', getLocalizedDayJs().format());
open.value = false; open.value = false;
} }
function setLast90Days() { function setLast90Days() {
emit('update:start', getLocalizedDayJs().subtract(89, 'days').startOf('day').format()); emit('update:start', getDayJsInstance()().subtract(90, 'days').format());
emit('update:end', getLocalizedDayJs().format()); emit('update:end', getDayJsInstance()().format());
open.value = false; open.value = false;
} }
@@ -143,38 +140,6 @@ function setLastYear() {
const organization = inject<ComputedRef<Organization>>('organization'); const organization = inject<ComputedRef<Organization>>('organization');
const displayLabel = computed(() => {
if (!startDay.value) return 'Pick a date';
if (!endDay.value) {
return getDateRangeLabel(startDay.value, startDay.value, {
dateFormat: organization?.value?.date_format,
});
}
return getDateRangeLabel(startDay.value, endDay.value, {
dateFormat: organization?.value?.date_format,
});
});
const nextRange = computed(() => {
if (!startDay.value || !endDay.value) return undefined;
return shiftDateRange(startDay.value, endDay.value, 1);
});
const nextDisabled = computed(
() =>
!props.allowFuture &&
!!nextRange.value &&
nextRange.value.start.isAfter(getLocalizedDayJs().endOf('day'))
);
function navigate(direction: DateRangeDirection) {
if (!startDay.value || !endDay.value) return;
const shifted = shiftDateRange(startDay.value, endDay.value, direction);
emit('update:start', shifted.start.format());
emit('update:end', shifted.end.format());
emit('submit');
}
watch(open, (value) => { watch(open, (value) => {
if (value === false) { if (value === false) {
emit('submit'); emit('submit');
@@ -183,120 +148,77 @@ watch(open, (value) => {
</script> </script>
<template> <template>
<DateRangeNavigator <Popover v-model:open="open">
:label="displayLabel" <PopoverTrigger as-child>
:next-disabled="nextDisabled" <Button
previous-test-id="date_range_picker_previous" variant="outline"
trigger-test-id="date_range_picker_display" :class="
next-test-id="date_range_picker_next" twMerge(
@previous="navigate(-1)" 'flex w-full items-center justify-between whitespace-nowrap h-[34px] text-start',
@next="navigate(1)"> !modelValue && 'text-muted-foreground'
<template #trigger="{ triggerClass }"> )
<Popover v-model:open="open"> ">
<PopoverTrigger as-child> <CalendarIcon class="-ml-0.5 text-text-quaternary h-4 w-4" />
<Button <template v-if="modelValue.start">
type="button" <template v-if="modelValue.end">
variant="ghost" {{ formatDate(modelValue.start.toString(), organization?.date_format) }}
size="sm" -
:class="twMerge(triggerClass, !modelValue.start && 'text-muted-foreground')" {{ formatDate(modelValue.end.toString(), organization?.date_format) }}
data-testid="date_range_picker_display"> </template>
<CalendarIcon class="text-text-quaternary h-4 w-4 shrink-0" /> <template v-else>
<span class="min-w-0 truncate">{{ displayLabel }}</span> {{ formatDate(modelValue.start.toString(), organization?.date_format) }}
</Button> </template>
</PopoverTrigger> </template>
<PopoverContent class="w-auto p-0"> <template v-else> Pick a date </template>
<div class="flex divide-x divide-border-secondary"> </Button>
<div </PopoverTrigger>
class="text-text-primary text-sm flex flex-col space-y-0.5 items-start py-2 px-2"> <PopoverContent class="w-auto p-0">
<Button <div class="flex divide-x divide-border-secondary">
variant="ghost" <div
size="sm" class="text-text-primary text-sm flex flex-col space-y-0.5 items-start py-2 px-2">
class="justify-start" <Button variant="ghost" size="sm" class="justify-start" @click="setToday"
@click="setToday" >Today</Button
>Today</Button >
> <Button variant="ghost" size="sm" class="justify-start" @click="setThisWeek"
<Button >This Week</Button
variant="ghost" >
size="sm" <Button variant="ghost" size="sm" class="justify-start" @click="setLastWeek"
class="justify-start" >Last Week</Button
@click="setThisWeek" >
>This Week</Button <Button variant="ghost" size="sm" class="justify-start" @click="setLast14Days"
> >Last 14 days</Button
<Button >
variant="ghost" <Button variant="ghost" size="sm" class="justify-start" @click="setThisMonth"
size="sm" >This Month</Button
class="justify-start" >
@click="setLastWeek" <Button variant="ghost" size="sm" class="justify-start" @click="setLastMonth"
>Last Week</Button >Last Month</Button
> >
<Button <Button variant="ghost" size="sm" class="justify-start" @click="setLast30Days"
variant="ghost" >Last 30 days</Button
size="sm" >
class="justify-start" <Button variant="ghost" size="sm" class="justify-start" @click="setLast90Days"
@click="setLast14Days" >Last 90 days</Button
>Last 14 Days</Button >
> <Button variant="ghost" size="sm" class="justify-start" @click="setLast12Months"
<Button >Last 12 months</Button
variant="ghost" >
size="sm" <Button variant="ghost" size="sm" class="justify-start" @click="setThisYear"
class="justify-start" >This year</Button
@click="setThisMonth" >
>This Month</Button <Button variant="ghost" size="sm" class="justify-start" @click="setLastYear"
> >Last year</Button
<Button >
variant="ghost" </div>
size="sm" <div class="pl-2">
class="justify-start" <RangeCalendar
@click="setLastMonth" v-model="modelValue"
>Last Month</Button initial-focus
> :number-of-months="2"
<Button :max-value="today"
variant="ghost" :week-starts-on="weekStartsOn" />
size="sm" </div>
class="justify-start" </div>
@click="setLast30Days" </PopoverContent>
>Last 30 Days</Button </Popover>
>
<Button
variant="ghost"
size="sm"
class="justify-start"
@click="setLast90Days"
>Last 90 Days</Button
>
<Button
variant="ghost"
size="sm"
class="justify-start"
@click="setLast12Months"
>Last 12 Months</Button
>
<Button
variant="ghost"
size="sm"
class="justify-start"
@click="setThisYear"
>This Year</Button
>
<Button
variant="ghost"
size="sm"
class="justify-start"
@click="setLastYear"
>Last Year</Button
>
</div>
<div class="pl-2">
<RangeCalendar
v-model="modelValue"
initial-focus
:number-of-months="2"
:max-value="allowFuture ? undefined : today"
:week-starts-on="weekStartsOn" />
</div>
</div>
</PopoverContent>
</Popover>
</template>
</DateRangeNavigator>
</template> </template>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { Dialog, DialogContent, DialogFooter } from './dialog/index'; import { Dialog, DialogContent, DialogFooter } from './dialog/index';
import { computed, nextTick } from 'vue'; import { computed } from 'vue';
const props = defineProps({ const props = defineProps({
show: { show: {
@@ -17,7 +17,7 @@ const props = defineProps({
}, },
}); });
const emit = defineEmits(['close', 'submit']); const emit = defineEmits(['close']);
const close = () => { const close = () => {
if (props.closeable) { if (props.closeable) {
@@ -25,25 +25,6 @@ const close = () => {
} }
}; };
// Ctrl+Enter (Cmd+Enter on macOS) submits the modal from any focused element inside it.
// Handled in the capture phase so child elements (buttons, dropdown triggers, inputs with
// their own Enter handlers) never see the keystroke and cannot open or double-submit.
async function onKeydownCapture(event: KeyboardEvent) {
if (event.key !== 'Enter' || !(event.ctrlKey || event.metaKey) || event.isComposing) {
return;
}
event.preventDefault();
event.stopPropagation();
// Inputs like the time and duration fields commit their value on blur, so blur first
// and let the resulting model updates settle before submitting.
const active = document.activeElement;
if (active instanceof HTMLElement) {
active.blur();
}
await nextTick();
emit('submit');
}
const maxWidthClass = computed(() => { const maxWidthClass = computed(() => {
return { return {
sm: 'sm:max-w-sm', sm: 'sm:max-w-sm',
@@ -58,7 +39,7 @@ const maxWidthClass = computed(() => {
<template> <template>
<Dialog :open="show" @update:open="close"> <Dialog :open="show" @update:open="close">
<DialogContent :class="maxWidthClass"> <DialogContent :class="maxWidthClass">
<div class="min-w-0" @keydown.capture="onKeydownCapture"> <div class="min-w-0">
<slot /> <slot />
</div> </div>

View File

@@ -72,7 +72,7 @@ const currentClientName = computed(() => {
</script> </script>
<template> <template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit"> <DialogModal closeable :show="show" @close="show = false">
<template #title> <template #title>
<div class="flex space-x-2"> <div class="flex space-x-2">
<span> Create Project </span> <span> Create Project </span>

View File

@@ -30,7 +30,7 @@ useFocus(tagNameInput, { initialValue: true });
</script> </script>
<template> <template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit"> <DialogModal closeable :show="show" @close="show = false">
<template #title> <template #title>
<div class="flex space-x-2"> <div class="flex space-x-2">
<span> Create Tags </span> <span> Create Tags </span>

View File

@@ -78,7 +78,7 @@ async function submit() {
</script> </script>
<template> <template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit"> <DialogModal closeable :show="show" @close="show = false">
<template #title> <template #title>
<div class="flex items-center space-x-2 text-amber-600 dark:text-amber-400"> <div class="flex items-center space-x-2 text-amber-600 dark:text-amber-400">
<Coffee class="w-5 h-5" /> <Coffee class="w-5 h-5" />

View File

@@ -126,7 +126,7 @@ const billableProxy = computed({
</script> </script>
<template> <template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit"> <DialogModal closeable :show="show" @close="show = false">
<template #title> <template #title>
<div class="flex space-x-2"> <div class="flex space-x-2">
<span> Create manual time entry </span> <span> Create manual time entry </span>

View File

@@ -164,7 +164,7 @@ const typeProxy = computed({
</script> </script>
<template> <template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit"> <DialogModal closeable :show="show" @close="show = false">
<template #title> <template #title>
<div class="flex space-x-2"> <div class="flex space-x-2">
<span> Edit time entry </span> <span> Edit time entry </span>

View File

@@ -16,7 +16,6 @@ import {
} from '@/packages/api/src'; } from '@/packages/api/src';
import { Checkbox } from '@/packages/ui/src'; import { Checkbox } from '@/packages/ui/src';
import { TagIcon, ExclamationTriangleIcon } from '@heroicons/vue/20/solid'; import { TagIcon, ExclamationTriangleIcon } from '@heroicons/vue/20/solid';
import { XMarkIcon } from '@heroicons/vue/16/solid';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '..'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '..';
import { Button } from '@/packages/ui/src/Buttons'; import { Button } from '@/packages/ui/src/Buttons';
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue'; import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
@@ -61,13 +60,6 @@ const projectId = ref<string | null>(null);
const billable = ref<boolean | undefined>(undefined); const billable = ref<boolean | undefined>(undefined);
const selectedTags = ref<string[]>([]); const selectedTags = ref<string[]>([]);
// Clearing the project puts both fields back to "leave unchanged", the same state the form
// returns to after a successful submit.
function resetProject() {
projectId.value = null;
taskId.value = undefined;
}
const timeEntryBillable = computed({ const timeEntryBillable = computed({
get: () => { get: () => {
if (billable.value === undefined) { if (billable.value === undefined) {
@@ -155,7 +147,7 @@ const showBreakWarning = computed(
</script> </script>
<template> <template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit"> <DialogModal closeable :show="show" @close="show = false">
<template #title> <template #title>
<div class="flex space-x-2"> <div class="flex space-x-2">
<span> Update {{ timeEntries.length }} time entries </span> <span> Update {{ timeEntries.length }} time entries </span>
@@ -190,59 +182,42 @@ const showBreakWarning = computed(
</Field> </Field>
<Field> <Field>
<FieldLabel for="project">Project</FieldLabel> <FieldLabel for="project">Project</FieldLabel>
<div class="flex items-center gap-1 min-w-0"> <TimeTrackerProjectTaskDropdown
<!-- the dropdown declares its own `class` prop, which goes to the v-model:project="projectId"
trigger button, so the growing has to happen on a wrapper --> v-model:task="taskId"
<div class="flex-1 min-w-0"> variant="input"
<TimeTrackerProjectTaskDropdown align="start"
v-model:project="projectId" size="default"
v-model:task="taskId" :clients
variant="input" :create-project
align="start" :create-client
size="default" :currency="currency"
:clients :organization-billable-rate="organizationBillableRate"
:create-project :can-create-project
:create-client empty-placeholder="Select project..."
:currency="currency" allow-reset
:organization-billable-rate="organizationBillableRate" :enable-estimated-time
:can-create-project :projects="projects"
empty-placeholder="Select project..." :tasks="tasks"></TimeTrackerProjectTaskDropdown>
:enable-estimated-time
:projects="projects"
:tasks="tasks"></TimeTrackerProjectTaskDropdown>
</div>
<button
v-if="projectId !== null"
type="button"
data-testid="project_reset_button"
class="p-1 rounded hover:bg-quaternary text-text-tertiary hover:text-text-primary"
@click="resetProject">
<XMarkIcon class="w-4 h-4" />
</button>
</div>
</Field> </Field>
<Field> <Field>
<FieldLabel>Tag</FieldLabel> <FieldLabel>Tag</FieldLabel>
<div class="flex items-center space-x-5"> <div class="flex space-x-5">
<!-- the dropdown root is `min-w-0`, so as a flex item it would shrink <TagDropdown
below its trigger and let the button overflow into the checkbox --> v-model="selectedTags"
<div class="shrink-0"> :create-tag
<TagDropdown :tags="tags"
v-model="selectedTags" :show-no-tag-option="false">
:create-tag <template #trigger>
:tags="tags" <Button variant="input" :disabled="removeAllTags">
:show-no-tag-option="false"> <TagIcon class="h-4 text-icon-default" />
<template #trigger> <span v-if="selectedTags.length > 0">
<Button variant="input" :disabled="removeAllTags"> Set {{ selectedTags.length }} tags
<TagIcon class="h-4 text-icon-default" /> </span>
<span v-if="selectedTags.length > 0"> <span v-else>Select Tags...</span>
Set {{ selectedTags.length }} tags </Button>
</span> </template>
<span v-else>Select Tags...</span> </TagDropdown>
</Button>
</template>
</TagDropdown>
</div>
<Field orientation="horizontal"> <Field orientation="horizontal">
<Checkbox id="no_tags" v-model:checked="removeAllTags"></Checkbox> <Checkbox id="no_tags" v-model:checked="removeAllTags"></Checkbox>
<FieldLabel for="no_tags">Remove all tags</FieldLabel> <FieldLabel for="no_tags">Remove all tags</FieldLabel>

View File

@@ -12,7 +12,7 @@ import type {
Client, Client,
} from '@/packages/api/src'; } from '@/packages/api/src';
import { PlusCircleIcon, MinusIcon } from '@heroicons/vue/16/solid'; import { PlusCircleIcon, MinusIcon, XMarkIcon } from '@heroicons/vue/16/solid';
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue'; import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
import { Button } from '@/packages/ui/src/Buttons'; import { Button } from '@/packages/ui/src/Buttons';
@@ -58,6 +58,7 @@ const props = withDefaults(
createClient: (client: CreateClientBody) => Promise<Client | undefined>; createClient: (client: CreateClientBody) => Promise<Client | undefined>;
currency: string; currency: string;
emptyPlaceholder?: string; emptyPlaceholder?: string;
allowReset?: boolean;
noProjectValue?: string | null; noProjectValue?: string | null;
enableEstimatedTime: boolean; enableEstimatedTime: boolean;
organizationBillableRate: number | null; organizationBillableRate: number | null;
@@ -69,6 +70,7 @@ const props = withDefaults(
}>(), }>(),
{ {
emptyPlaceholder: 'No Project', emptyPlaceholder: 'No Project',
allowReset: false,
noProjectValue: NO_PROJECT_ID, noProjectValue: NO_PROJECT_ID,
variant: 'ghost', variant: 'ghost',
align: 'center', align: 'center',
@@ -556,6 +558,12 @@ function selectProject(projectId: string) {
emit('changed', project.value, task.value); emit('changed', project.value, task.value);
} }
function resetProject() {
project.value = null;
task.value = null;
emit('changed', project.value, task.value);
}
const showCreateProject = ref(false); const showCreateProject = ref(false);
</script> </script>
@@ -563,28 +571,40 @@ const showCreateProject = ref(false);
<Dropdown v-model="open" :close-on-content-click="false" :align="props.align"> <Dropdown v-model="open" :close-on-content-click="false" :align="props.align">
<template #trigger> <template #trigger>
<slot name="trigger"> <slot name="trigger">
<Button <div class="flex items-center gap-1">
:variant="props.variant" <Button
:size="props.size" :variant="props.variant"
:class="twMerge('w-full justify-start overflow-hidden', props.class)"> :size="props.size"
<div :class="twMerge('w-full justify-start overflow-hidden', props.class)">
class="w-2.5 h-2.5 rounded-full shrink-0" <div
:style="{ backgroundColor: selectedProjectColor }"></div> class="w-2.5 h-2.5 rounded-full shrink-0"
<span class="truncate shrink-[1] text-text-primary">{{ :style="{ backgroundColor: selectedProjectColor }"></div>
selectedProjectName <span class="truncate shrink-[1] text-text-primary">{{
}}</span> selectedProjectName
<template v-if="currentTask"> }}</span>
<ChevronRightIcon class="!size-3 text-text-primary shrink-0 -mx-1" /> <template v-if="currentTask">
<span class="truncate shrink-[100]">{{ currentTask.name }}</span> <ChevronRightIcon class="!size-3 text-text-primary shrink-0 -mx-1" />
</template> <span class="truncate shrink-[100]">{{ currentTask.name }}</span>
<template v-if="selectedClientName"> </template>
<span class="dark:text-text-tertiary text-text-quaternary shrink-0">•</span> <template v-if="selectedClientName">
<span <span class="dark:text-text-tertiary text-text-quaternary shrink-0"
class="truncate shrink-[200] dark:text-text-tertiary text-text-quaternary" >•</span
>{{ selectedClientName }}</span >
> <span
</template> class="truncate shrink-[200] dark:text-text-tertiary text-text-quaternary"
</Button> >{{ selectedClientName }}</span
>
</template>
</Button>
<button
v-if="allowReset && project !== null"
type="button"
data-testid="project_reset_button"
class="p-1 rounded hover:bg-quaternary text-text-tertiary hover:text-text-primary"
@click.stop="resetProject">
<XMarkIcon class="w-4 h-4" />
</button>
</div>
</slot> </slot>
</template> </template>
<template #content> <template #content>

View File

@@ -132,7 +132,6 @@ import FullCalendarDayHeader from './FullCalendar/FullCalendarDayHeader.vue';
import FullCalendarEventContent from './FullCalendar/FullCalendarEventContent.vue'; import FullCalendarEventContent from './FullCalendar/FullCalendarEventContent.vue';
import TimeEntryCalendar from './FullCalendar/TimeEntryCalendar.vue'; import TimeEntryCalendar from './FullCalendar/TimeEntryCalendar.vue';
import DateRangePicker from './Input/DateRangePicker.vue'; import DateRangePicker from './Input/DateRangePicker.vue';
import DateRangeNavigator from './Input/DateRangeNavigator.vue';
import { Label } from './label/index'; import { Label } from './label/index';
import { import {
NumberField, NumberField,
@@ -212,7 +211,6 @@ export {
ContextMenuSubTrigger, ContextMenuSubTrigger,
ContextMenuTrigger, ContextMenuTrigger,
DateRangePicker, DateRangePicker,
DateRangeNavigator,
Dialog, Dialog,
DialogClose, DialogClose,
DialogContent, DialogContent,

View File

@@ -1,124 +0,0 @@
import dayjs from 'dayjs';
import { afterEach, describe, expect, test, vi } from 'vitest';
import { getDayJsInstance, getLocalizedDayJs } from './time';
import { getDateRangeLabel, shiftDateRange } from './dateRange';
const reference = dayjs('2026-09-21');
afterEach(() => {
vi.mocked(window.getTimezoneSetting).mockReturnValue('UTC');
});
describe('getDateRangeLabel', () => {
test('labels relative weeks', () => {
getDayJsInstance();
expect(
getDateRangeLabel(reference.startOf('week'), reference.endOf('week'), {
referenceDate: reference,
})
).toBe('This Week');
expect(
getDateRangeLabel(
reference.subtract(1, 'week').startOf('week'),
reference.subtract(1, 'week').endOf('week'),
{ referenceDate: reference }
)
).toBe('Last Week');
});
test('labels common rolling ranges', () => {
expect(
getDateRangeLabel(reference.subtract(13, 'day'), reference.endOf('day'), {
referenceDate: reference,
})
).toBe('Last 14 Days');
});
test('labels full calendar months and years', () => {
expect(
getDateRangeLabel(dayjs('2025-09-01'), dayjs('2025-09-30'), {
referenceDate: reference,
})
).toBe('September 2025');
expect(
getDateRangeLabel(dayjs('2024-01-01'), dayjs('2024-12-31'), {
referenceDate: reference,
})
).toBe('2024');
});
test('keeps relative labels for the current periods', () => {
expect(
getDateRangeLabel(reference.startOf('month'), reference.endOf('month'), {
referenceDate: reference,
})
).toBe('This Month');
expect(
getDateRangeLabel(reference.startOf('year'), reference.endOf('year'), {
referenceDate: reference,
})
).toBe('This Year');
});
test('uses the organization date format for custom ranges', () => {
expect(
getDateRangeLabel(dayjs('2026-08-03'), dayjs('2026-08-12'), {
dateFormat: 'slash-separated-dd-mm-yyyy',
referenceDate: reference,
})
).toBe('03/08/2026 – 12/08/2026');
});
test('renders a single historical day without duplicating it', () => {
expect(
getDateRangeLabel(dayjs('2026-08-03'), dayjs('2026-08-03'), {
referenceDate: reference,
})
).toBe('Aug 3, 2026');
});
});
describe('shiftDateRange', () => {
test('moves full weeks as calendar periods', () => {
getDayJsInstance();
const shifted = shiftDateRange(reference.startOf('week'), reference.endOf('week'), -1);
expect(shifted.start.format('YYYY-MM-DD')).toBe('2026-09-14');
expect(shifted.end.format('YYYY-MM-DD')).toBe('2026-09-20');
});
test('moves full months without truncating longer months', () => {
const shifted = shiftDateRange(dayjs('2026-02-01'), dayjs('2026-02-28'), 1);
expect(shifted.start.format('YYYY-MM-DD')).toBe('2026-03-01');
expect(shifted.end.format('YYYY-MM-DD')).toBe('2026-03-31');
});
test('moves custom ranges by their inclusive duration', () => {
const shifted = shiftDateRange(dayjs('2026-09-01'), dayjs('2026-09-14'), -1);
expect(shifted.start.format('YYYY-MM-DD')).toBe('2026-08-18');
expect(shifted.end.format('YYYY-MM-DD')).toBe('2026-08-31');
});
test('normalizes custom range offsets across the spring DST transition', () => {
vi.mocked(window.getTimezoneSetting).mockReturnValue('Europe/Vienna');
const shifted = shiftDateRange(
getLocalizedDayJs('2026-03-14T23:00:00Z'),
getLocalizedDayJs('2026-03-27T23:00:00Z'),
1
);
expect(shifted.start.format()).toBe('2026-03-29T00:00:00+01:00');
expect(shifted.end.format()).toBe('2026-04-11T23:59:59+02:00');
});
test('normalizes custom range offsets across the autumn DST transition', () => {
vi.mocked(window.getTimezoneSetting).mockReturnValue('Europe/Vienna');
const shifted = shiftDateRange(
getLocalizedDayJs('2026-10-17T22:00:00Z'),
getLocalizedDayJs('2026-10-30T23:00:00Z'),
1
);
expect(shifted.start.format()).toBe('2026-11-01T00:00:00+01:00');
expect(shifted.end.format()).toBe('2026-11-14T23:59:59+01:00');
});
});

View File

@@ -1,135 +0,0 @@
import type { Dayjs } from 'dayjs';
import { formatDate, getDayJsInstance, getLocalizedDayJs, type DateFormat } from './time';
export type DateRangeDirection = -1 | 1;
function isSameDay(left: Dayjs, right: Dayjs): boolean {
return left.isSame(right, 'day');
}
function isRange(start: Dayjs, end: Dayjs, expectedStart: Dayjs, expectedEnd: Dayjs): boolean {
return isSameDay(start, expectedStart) && isSameDay(end, expectedEnd);
}
function formatCompactRange(start: Dayjs, end: Dayjs): string {
if (start.year() !== end.year()) {
return `${start.format('MMM D, YYYY')} – ${end.format('MMM D, YYYY')}`;
}
if (start.month() !== end.month()) {
return `${start.format('MMM D')} – ${end.format('MMM D, YYYY')}`;
}
return `${start.format('MMM D')} – ${end.format('D, YYYY')}`;
}
/**
* Returns a human-friendly label for an inclusive date range. Relative labels
* are intentionally shared by reporting, the timesheet, and the calendar.
*/
export function getDateRangeLabel(
start: Dayjs,
end: Dayjs,
options: { dateFormat?: DateFormat; referenceDate?: Dayjs } = {}
): string {
// Ensure the configured first day of the week is applied before using startOf('week').
getDayJsInstance();
const reference = (options.referenceDate ?? getLocalizedDayJs()).startOf('day');
const rangeStart = start.startOf('day');
const rangeEnd = end.startOf('day');
const relativeDays: Array<[number, string]> = [
[0, 'Today'],
[-1, 'Yesterday'],
[1, 'Tomorrow'],
];
for (const [offset, label] of relativeDays) {
const day = reference.add(offset, 'day');
if (isRange(rangeStart, rangeEnd, day, day)) return label;
}
const relativePeriods: Array<['week' | 'month' | 'year', number, string]> = [
['week', 0, 'This Week'],
['week', -1, 'Last Week'],
['week', 1, 'Next Week'],
['month', 0, 'This Month'],
['month', -1, 'Last Month'],
['month', 1, 'Next Month'],
['year', 0, 'This Year'],
['year', -1, 'Last Year'],
['year', 1, 'Next Year'],
];
for (const [unit, offset, label] of relativePeriods) {
const period = reference.add(offset, unit);
if (isRange(rangeStart, rangeEnd, period.startOf(unit), period.endOf(unit))) return label;
}
// Named calendar periods outside the relative windows above, e.g. "May 2025"
// for a full month and "2025" for a full year.
const monthStart = rangeStart.startOf('month');
if (isRange(rangeStart, rangeEnd, monthStart, monthStart.endOf('month'))) {
return rangeStart.format('MMMM YYYY');
}
const yearStart = rangeStart.startOf('year');
if (isRange(rangeStart, rangeEnd, yearStart, yearStart.endOf('year'))) {
return rangeStart.format('YYYY');
}
const rollingRanges: Array<[number, string]> = [
[14, 'Last 14 Days'],
[30, 'Last 30 Days'],
[90, 'Last 90 Days'],
];
for (const [days, label] of rollingRanges) {
if (isRange(rangeStart, rangeEnd, reference.subtract(days - 1, 'day'), reference)) {
return label;
}
}
if (isRange(rangeStart, rangeEnd, reference.subtract(12, 'month'), reference)) {
return 'Last 12 Months';
}
if (isSameDay(rangeStart, rangeEnd)) {
return options.dateFormat
? formatDate(rangeStart.format('YYYY-MM-DD'), options.dateFormat)
: rangeStart.format('MMM D, YYYY');
}
if (options.dateFormat) {
return `${formatDate(rangeStart.format('YYYY-MM-DD'), options.dateFormat)} – ${formatDate(
rangeEnd.format('YYYY-MM-DD'),
options.dateFormat
)}`;
}
return formatCompactRange(rangeStart, rangeEnd);
}
/** Shift an inclusive range by one period, preserving full calendar periods. */
export function shiftDateRange(
start: Dayjs,
end: Dayjs,
direction: DateRangeDirection
): { start: Dayjs; end: Dayjs } {
getDayJsInstance();
const rangeStart = start.startOf('day');
const rangeEnd = end.endOf('day');
if (isRange(rangeStart, rangeEnd, rangeStart.startOf('year'), rangeStart.endOf('year'))) {
const shiftedStart = rangeStart.add(direction, 'year').startOf('year');
return { start: shiftedStart, end: shiftedStart.endOf('year') };
}
if (isRange(rangeStart, rangeEnd, rangeStart.startOf('month'), rangeStart.endOf('month'))) {
const shiftedStart = rangeStart.add(direction, 'month').startOf('month');
return { start: shiftedStart, end: shiftedStart.endOf('month') };
}
if (isRange(rangeStart, rangeEnd, rangeStart.startOf('week'), rangeStart.endOf('week'))) {
const shiftedStart = rangeStart.add(direction, 'week').startOf('week');
return { start: shiftedStart, end: shiftedStart.endOf('week') };
}
const days = rangeEnd.startOf('day').diff(rangeStart, 'day') + 1;
return {
start: rangeStart.add(days * direction, 'day').startOf('day'),
end: rangeEnd.add(days * direction, 'day').endOf('day'),
};
}

View File

@@ -1,4 +0,0 @@
"Project","Client","Status","Visibility","Tasks","Tracked (h)","Estimated (h)","Remaining (h)","Overage (h)","Progress(%)","Project members","Project manager","Note"
"Project for Big Company","Big Company","Active","Public","Task 1, Task 2, Task 3","1.38","","","","","Constantin Graf","",""
"Project without Client","","Active","Public","","0.00","","","","","Constantin Graf","",""
"TEST","Big Company","Active","Public","","0.00","","","","","Constantin Graf","",""
1 Project Client Status Visibility Tasks Tracked (h) Estimated (h) Remaining (h) Overage (h) Progress(%) Project members Project manager Note
2 Project for Big Company Big Company Active Public Task 1, Task 2, Task 3 1.38 Constantin Graf
3 Project without Client Active Public 0.00 Constantin Graf
4 TEST Big Company Active Public 0.00 Constantin Graf

View File

@@ -119,24 +119,6 @@ class ClockifyProjectsImporterTest extends ImporterTestAbstract
); );
} }
public function test_import_of_test_file_without_billability_column_defaults_to_not_billable(): void
{
// Arrange
$organization = Organization::factory()->create();
$timezone = 'Europe/Vienna';
$importer = new ClockifyProjectsImporter;
$importer->init($organization);
// Some Clockify exports don't contain a "Billability" column.
$data = Storage::disk('testfiles')->get('clockify_projects_import_test_5.csv');
// Act
$importer->importData($data, $timezone);
// Assert
$project = Project::query()->where('organization_id', $organization->id)->where('name', 'Project for Big Company')->firstOrFail();
$this->assertFalse($project->is_billable);
}
public function test_import_supports_activities_column_alias_for_tasks(): void public function test_import_supports_activities_column_alias_for_tasks(): void
{ {
// Arrange // Arrange

View File

@@ -66,27 +66,4 @@ class GenericTimeEntriesImporterTest extends ImporterTestAbstract
$this->assertSame(0, $report->projectsCreated); $this->assertSame(0, $report->projectsCreated);
$this->assertSame(0, $report->clientsCreated); $this->assertSame(0, $report->clientsCreated);
} }
public function test_import_fails_if_task_name_is_too_long(): void
{
// Arrange
$organization = Organization::factory()->create();
$timezone = 'Europe/Vienna';
$importer = new GenericTimeEntriesImporter;
$importer->init($organization);
$taskName = str_repeat('a', 501);
$data = "description,billable,client,project,tags,start,end,task,user_name,user_email\n".
'"Working hard","true","Big Company","Project for Big Company","","2024-03-04T09:23:00Z","2024-03-04T10:23:01Z","'.$taskName.'","Peter Tester","peter.test@email.test"';
// Act
try {
$importer->importData($data, $timezone);
} catch (ImportException $e) {
// Assert
$this->assertSame('Task name ("'.$taskName.'") is too long, maximum length is 500 characters', $e->getMessage());
return;
}
$this->fail();
}
} }