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
20 changed files with 323 additions and 567 deletions

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

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

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

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

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