mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-10-08 13:53:17 +01:00
Compare commits
2 Commits
feature/ex
...
project-ta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb7233dfba | ||
|
|
398a81798b |
2
.github/workflows/build-private.yml
vendored
2
.github/workflows/build-private.yml
vendored
@@ -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
|
||||||
|
|||||||
136
.github/workflows/phpunit-extensions.yml
vendored
136
.github/workflows/phpunit-extensions.yml
vendored
@@ -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
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user