mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-10-08 13:53:17 +01:00
Compare commits
1 Commits
feature/ad
...
feature/do
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd05c37d86 |
@@ -1,7 +1,6 @@
|
|||||||
ARG PHP_VERSION=8.3
|
ARG PHP_VERSION=8.3
|
||||||
ARG FRANKENPHP_VERSION=1.8
|
ARG FRANKENPHP_VERSION=1.11
|
||||||
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/"
|
||||||
|
|
||||||
@@ -16,13 +15,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 v2.10.0 \
|
xcaddy build \
|
||||||
--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} AS base
|
FROM dunglas/frankenphp:${FRANKENPHP_VERSION}-php${PHP_VERSION}
|
||||||
|
|
||||||
COPY --from=upstream /usr/local/bin/frankenphp /usr/local/bin/frankenphp
|
COPY --from=upstream /usr/local/bin/frankenphp /usr/local/bin/frankenphp
|
||||||
|
|
||||||
@@ -32,26 +31,28 @@ 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 WWWUSER=1000
|
ARG USER_ID=1000
|
||||||
ARG WWWGROUP=1000
|
ARG GROUP_ID=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} \
|
||||||
USER=octane \
|
LANG=C.UTF-8 \
|
||||||
ROOT=${APP_DIR} \
|
USER=laravel \
|
||||||
APP_ENV=${APP_ENV} \
|
ROOT=/var/www/html \
|
||||||
|
APP_ENV=production \
|
||||||
|
COMPOSER_ALLOW_SUPERUSER=1 \
|
||||||
COMPOSER_FUND=0 \
|
COMPOSER_FUND=0 \
|
||||||
COMPOSER_MAX_PARALLEL_HTTP=24 \
|
COMPOSER_MAX_PARALLEL_HTTP=48 \
|
||||||
XDG_CONFIG_HOME=${APP_DIR}/.config \
|
WITH_HORIZON=false \
|
||||||
XDG_DATA_HOME=${APP_DIR}/.data \
|
WITH_SCHEDULER=false \
|
||||||
SERVER_NAME=${APP_HOST}
|
WITH_REVERB=false \
|
||||||
|
WITH_SSR=false
|
||||||
|
|
||||||
|
ENV XDG_CONFIG_HOME=${ROOT}/.config XDG_DATA_HOME=${ROOT}/.data
|
||||||
|
|
||||||
WORKDIR ${ROOT}
|
WORKDIR ${ROOT}
|
||||||
|
|
||||||
@@ -60,6 +61,9 @@ 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 \
|
||||||
@@ -68,40 +72,36 @@ 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 \
|
||||||
libbrotli-dev \
|
# && curl -fsSL https://bun.sh/install | BUN_INSTALL=/usr bash \
|
||||||
# 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 \
|
||||||
memcached \
|
ffi \
|
||||||
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/* \
|
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* /var/log/lastlog /var/log/faillog
|
||||||
&& rm /var/log/lastlog /var/log/faillog
|
|
||||||
|
|
||||||
RUN arch="$(uname -m)" \
|
RUN arch="$(uname -m)" \
|
||||||
&& case "$arch" in \
|
&& case "$arch" in \
|
||||||
@@ -111,106 +111,64 @@ 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.29/${_cronic_fname}" \
|
&& wget -q "https://github.com/aptible/supercronic/releases/download/v0.2.38/${_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 ${WWWGROUP} ${USER} \
|
&& groupadd --force -g ${GROUP_ID} ${USER} \
|
||||||
&& useradd -ms /bin/bash --no-log-init --no-user-group -g ${WWWGROUP} -u ${WWWUSER} ${USER} \
|
&& useradd -ms /bin/bash --no-log-init --no-user-group -g ${GROUP_ID} -u ${USER_ID} ${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
|
||||||
|
|
||||||
USER ${USER}
|
COPY --link --from=vendor /usr/bin/composer /usr/bin/composer
|
||||||
|
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 && chmod -R a+rw storage
|
bootstrap/cache \
|
||||||
|
&& chmod +x /usr/local/bin/start-container /usr/local/bin/healthcheck
|
||||||
|
|
||||||
#RUN composer install \
|
RUN composer dump-autoload \
|
||||||
# --classmap-authoritative \
|
--optimize \
|
||||||
# --no-interaction \
|
--apcu \
|
||||||
# --no-ansi \
|
--no-dev
|
||||||
# --no-dev \
|
|
||||||
# && composer clear-cache
|
|
||||||
|
|
||||||
RUN cat .env
|
#RUN bun run build
|
||||||
#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=5s --interval=2s --timeout=5s --retries=8 CMD healthcheck || exit 1
|
#HEALTHCHECK --start-period=30s --interval=10s --timeout=3s --retries=3 CMD healthcheck || exit 1
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ 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,6 +3,14 @@
|
|||||||
|
|
||||||
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}
|
||||||
}
|
}
|
||||||
@@ -20,7 +28,7 @@
|
|||||||
|
|
||||||
{$CADDY_SERVER_SERVER_NAME} {
|
{$CADDY_SERVER_SERVER_NAME} {
|
||||||
log {
|
log {
|
||||||
level WARN
|
level {$CADDY_SERVER_LOG_LEVEL:WARN}
|
||||||
|
|
||||||
format filter {
|
format filter {
|
||||||
wrap {$CADDY_SERVER_LOGGER}
|
wrap {$CADDY_SERVER_LOGGER}
|
||||||
@@ -60,7 +68,6 @@
|
|||||||
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,65 +1,18 @@
|
|||||||
[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-port=2019 --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-host=0.0.0.0 --admin-port=2019 --log-level=WARN --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
|
files = /etc/supervisord.conf /etc/supervisor/conf.d/supervisord.services.conf
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
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 = 16M
|
realpath_cache_size = 32M
|
||||||
realpath_cache_ttl = 360
|
realpath_cache_ttl = 720
|
||||||
|
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}
|
||||||
@@ -11,18 +12,24 @@ date.timezone = ${TZ:-UTC}
|
|||||||
[Opcache]
|
[Opcache]
|
||||||
opcache.enable = 1
|
opcache.enable = 1
|
||||||
opcache.enable_cli = 1
|
opcache.enable_cli = 1
|
||||||
opcache.memory_consumption = 256M
|
opcache.memory_consumption = 256
|
||||||
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 = function
|
opcache.jit = tracing
|
||||||
opcache.jit_prof_threshold = 0.001
|
opcache.jit_hot_loop = 16
|
||||||
|
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,8 +1,6 @@
|
|||||||
[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]
|
||||||
|
|
||||||
@@ -10,4 +8,4 @@ pidfile = /var/run/supervisord.pid
|
|||||||
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,6 +4,8 @@ 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
|
||||||
|
|||||||
15
docker/prod/deployment/supervisord.inertia.conf
Normal file
15
docker/prod/deployment/supervisord.inertia.conf
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
[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,6 +4,8 @@ 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,6 +4,8 @@ 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
|
||||||
|
|||||||
69
docker/prod/deployment/supervisord.services.conf
Normal file
69
docker/prod/deployment/supervisord.services.conf
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
[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,6 +4,8 @@ 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
|
||||||
|
|||||||
@@ -252,19 +252,19 @@ test('test that calendar page loads and displays time entries', async ({ page, c
|
|||||||
test('test that calendar navigation buttons work', async ({ page }) => {
|
test('test that calendar navigation buttons work', async ({ page }) => {
|
||||||
await goToCalendar(page);
|
await goToCalendar(page);
|
||||||
await expect(page.locator('.fc')).toBeVisible();
|
await expect(page.locator('.fc')).toBeVisible();
|
||||||
await expect(page.getByTestId('calendar-title')).toContainText('This Week');
|
|
||||||
|
|
||||||
// Navigate between named periods.
|
// Click the "next" button to navigate forward
|
||||||
|
await page.getByRole('button', { name: 'Next' }).click();
|
||||||
|
await expect(page.locator('.fc')).toBeVisible();
|
||||||
|
|
||||||
|
// Click the "prev" button to navigate back
|
||||||
await page.getByRole('button', { name: 'Previous' }).click();
|
await page.getByRole('button', { name: 'Previous' }).click();
|
||||||
await expect(page.getByTestId('calendar-title')).toContainText('Last Week');
|
await expect(page.locator('.fc')).toBeVisible();
|
||||||
await page.getByRole('button', { name: 'Next' }).click();
|
|
||||||
await expect(page.getByTestId('calendar-title')).toContainText('This Week');
|
|
||||||
|
|
||||||
// Navigate forward first, then use the range button to return to today.
|
// Navigate forward first, then click today
|
||||||
await page.getByRole('button', { name: 'Next' }).click();
|
await page.getByRole('button', { name: 'Next' }).click();
|
||||||
await expect(page.getByTestId('calendar-title')).toContainText('Next Week');
|
|
||||||
await page.getByRole('button', { name: 'today' }).click();
|
await page.getByRole('button', { name: 'today' }).click();
|
||||||
await expect(page.getByTestId('calendar-title')).toContainText('This Week');
|
await expect(page.locator('.fc')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('test that editing time entry description via calendar modal works', async ({ page, ctx }) => {
|
test('test that editing time entry description via calendar modal works', async ({ page, ctx }) => {
|
||||||
|
|||||||
@@ -18,25 +18,6 @@ import {
|
|||||||
// Each test registers a new user and creates test data via API
|
// Each test registers a new user and creates test data via API
|
||||||
test.describe.configure({ timeout: 30000 });
|
test.describe.configure({ timeout: 30000 });
|
||||||
|
|
||||||
test('date range picker labels and navigates reporting periods', async ({ page }) => {
|
|
||||||
await goToReporting(page);
|
|
||||||
|
|
||||||
const range = page.getByTestId('date_range_picker_display');
|
|
||||||
const previous = page.getByTestId('date_range_picker_previous');
|
|
||||||
const next = page.getByTestId('date_range_picker_next');
|
|
||||||
|
|
||||||
await expect(range).toContainText('Last 14 Days');
|
|
||||||
await expect(next).toBeDisabled();
|
|
||||||
|
|
||||||
await Promise.all([waitForReportingUpdate(page), previous.click()]);
|
|
||||||
await expect(range).not.toContainText('Last 14 Days');
|
|
||||||
|
|
||||||
// The initial reporting range is still fresh in Vue Query's cache, so returning to it
|
|
||||||
// does not necessarily trigger another aggregate request.
|
|
||||||
await next.click();
|
|
||||||
await expect(range).toContainText('Last 14 Days');
|
|
||||||
});
|
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────
|
||||||
// Project Multiselect Dropdown Tests
|
// Project Multiselect Dropdown Tests
|
||||||
// ──────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -409,20 +409,20 @@ test('navigating to previous week shows entries from that week', async ({ page,
|
|||||||
test('can navigate forward and return to current week', async ({ page }) => {
|
test('can navigate forward and return to current week', async ({ page }) => {
|
||||||
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
|
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
|
||||||
|
|
||||||
// Should show "This Week"
|
// Should show "This week"
|
||||||
await expect(page.getByTestId('timesheet_week_display')).toContainText('This Week');
|
await expect(page.getByTestId('timesheet_week_display')).toContainText('This week');
|
||||||
|
|
||||||
// Go to next week — the text assertions below auto-retry until the
|
// Go to next week — the text assertions below auto-retry until the
|
||||||
// header label flips.
|
// header label flips.
|
||||||
await page.getByTestId('timesheet_next_week').click();
|
await page.getByTestId('timesheet_next_week').click();
|
||||||
|
|
||||||
// Should no longer show "This Week"
|
// Should no longer show "This week"
|
||||||
await expect(page.getByTestId('timesheet_week_display')).not.toContainText('This Week');
|
await expect(page.getByTestId('timesheet_week_display')).not.toContainText('This week');
|
||||||
|
|
||||||
// Go back to this week
|
// Go back to this week
|
||||||
await page.getByTestId('timesheet_week_display').click();
|
await page.getByTestId('timesheet_week_display').click();
|
||||||
|
|
||||||
await expect(page.getByTestId('timesheet_week_display')).toContainText('This Week');
|
await expect(page.getByTestId('timesheet_week_display')).toContainText('This week');
|
||||||
});
|
});
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ const { handleApiRequestNotifications } = useNotificationsStore();
|
|||||||
|
|
||||||
const startDate = useSessionStorage<string>(
|
const startDate = useSessionStorage<string>(
|
||||||
'reporting-start-date',
|
'reporting-start-date',
|
||||||
getLocalizedDayJs(getDayJsInstance()().format()).subtract(13, 'd').format()
|
getLocalizedDayJs(getDayJsInstance()().format()).subtract(14, 'd').format()
|
||||||
);
|
);
|
||||||
const endDate = useSessionStorage<string>(
|
const endDate = useSessionStorage<string>(
|
||||||
'reporting-end-date',
|
'reporting-end-date',
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import DateRangeNavigator from '@/packages/ui/src/Input/DateRangeNavigator.vue';
|
import { Button } from '@/packages/ui/src/Buttons';
|
||||||
|
import { ChevronLeftIcon, ChevronRightIcon, CalendarIcon } from '@heroicons/vue/20/solid';
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
|
isCurrentWeek: boolean;
|
||||||
weekNumber: number;
|
weekNumber: number;
|
||||||
rangeLabel: string;
|
weekRangeDisplay: string;
|
||||||
weekTotalFormatted: string;
|
weekTotalFormatted: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
@@ -17,16 +19,33 @@ defineEmits<{
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-wrap items-center justify-between gap-4 mb-4 px-2 sm:px-4 lg:px-6">
|
<div class="flex flex-wrap items-center justify-between gap-4 mb-4 px-2 sm:px-4 lg:px-6">
|
||||||
<!-- Left: Week navigation -->
|
<!-- Left: Week navigation -->
|
||||||
<DateRangeNavigator
|
<div class="flex items-center gap-2">
|
||||||
:label="rangeLabel"
|
<Button
|
||||||
:secondary-label="`W${weekNumber}`"
|
variant="outline"
|
||||||
previous-test-id="timesheet_prev_week"
|
size="icon"
|
||||||
trigger-test-id="timesheet_week_display"
|
class="h-8 w-8"
|
||||||
next-test-id="timesheet_next_week"
|
data-testid="timesheet_prev_week"
|
||||||
trigger-aria-label="Return to current week"
|
@click="$emit('previous')">
|
||||||
@previous="$emit('previous')"
|
<ChevronLeftIcon class="h-4 w-4" />
|
||||||
@next="$emit('next')"
|
</Button>
|
||||||
@select="$emit('current')" />
|
<button
|
||||||
|
data-testid="timesheet_week_display"
|
||||||
|
class="flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-text-primary hover:bg-card-background rounded-md transition"
|
||||||
|
@click="$emit('current')">
|
||||||
|
<CalendarIcon class="h-4 w-4 text-icon-default" />
|
||||||
|
<span v-if="isCurrentWeek">This week</span>
|
||||||
|
<span v-else>{{ weekRangeDisplay }}</span>
|
||||||
|
<span class="text-text-tertiary">· W{{ weekNumber }}</span>
|
||||||
|
</button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
class="h-8 w-8"
|
||||||
|
data-testid="timesheet_next_week"
|
||||||
|
@click="$emit('next')">
|
||||||
|
<ChevronRightIcon class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Right: Week total -->
|
<!-- Right: Week total -->
|
||||||
<div class="flex items-center gap-2.5">
|
<div class="flex items-center gap-2.5">
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ type TimeEntryRoundingType = 'up' | 'down' | 'nearest';
|
|||||||
|
|
||||||
const startDate = useSessionStorage<string>(
|
const startDate = useSessionStorage<string>(
|
||||||
'reporting-start-date',
|
'reporting-start-date',
|
||||||
getLocalizedDayJs(getDayJsInstance()().format()).subtract(13, 'd').format()
|
getLocalizedDayJs(getDayJsInstance()().format()).subtract(14, 'd').format()
|
||||||
);
|
);
|
||||||
const endDate = useSessionStorage<string>(
|
const endDate = useSessionStorage<string>(
|
||||||
'reporting-end-date',
|
'reporting-end-date',
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ import {
|
|||||||
getLocalizedDayJs,
|
getLocalizedDayJs,
|
||||||
} from '@/packages/ui/src/utils/time';
|
} from '@/packages/ui/src/utils/time';
|
||||||
import { getBreakPlacementHint } from '@/packages/ui/src/utils/breakPlacement';
|
import { getBreakPlacementHint } from '@/packages/ui/src/utils/breakPlacement';
|
||||||
import { getDateRangeLabel } from '@/packages/ui/src/utils/dateRange';
|
|
||||||
import { useTimesheetWeek } from '@/utils/timesheet/useTimesheetWeek';
|
import { useTimesheetWeek } from '@/utils/timesheet/useTimesheetWeek';
|
||||||
import { useTimesheetCellMutations } from '@/utils/timesheet/useTimesheetCellMutations';
|
import { useTimesheetCellMutations } from '@/utils/timesheet/useTimesheetCellMutations';
|
||||||
import { useTimesheetRowMutations } from '@/utils/timesheet/useTimesheetRowMutations';
|
import { useTimesheetRowMutations } from '@/utils/timesheet/useTimesheetRowMutations';
|
||||||
@@ -45,6 +44,7 @@ const {
|
|||||||
weekEnd,
|
weekEnd,
|
||||||
weekDays,
|
weekDays,
|
||||||
weekNumber,
|
weekNumber,
|
||||||
|
isCurrentWeek,
|
||||||
todayDate,
|
todayDate,
|
||||||
goToPreviousWeek,
|
goToPreviousWeek,
|
||||||
goToNextWeek,
|
goToNextWeek,
|
||||||
@@ -109,9 +109,13 @@ const weekTotalFormatted = computed(() =>
|
|||||||
formatHumanReadableDuration(grandTotal.value, intervalFormat.value, numberFormat.value)
|
formatHumanReadableDuration(grandTotal.value, intervalFormat.value, numberFormat.value)
|
||||||
);
|
);
|
||||||
|
|
||||||
const weekRangeLabel = computed(() =>
|
const weekRangeDisplay = computed(() => {
|
||||||
getDateRangeLabel(weekStart.value, weekStart.value.add(6, 'day'))
|
const start = weekStart.value;
|
||||||
);
|
const end = start.add(6, 'day');
|
||||||
|
return start.month() === end.month()
|
||||||
|
? `${start.format('MMM D')} - ${end.format('D')}`
|
||||||
|
: `${start.format('MMM D')} - ${end.format('MMM D')}`;
|
||||||
|
});
|
||||||
|
|
||||||
// ── Cell / row mutation handlers ──────────────────────────────────
|
// ── Cell / row mutation handlers ──────────────────────────────────
|
||||||
const {
|
const {
|
||||||
@@ -204,8 +208,9 @@ async function createTag(name: string): Promise<Tag | undefined> {
|
|||||||
<AppLayout title="Timesheet" data-testid="timesheet_view">
|
<AppLayout title="Timesheet" data-testid="timesheet_view">
|
||||||
<div class="pt-5 lg:pt-8 pb-4 lg:pb-6">
|
<div class="pt-5 lg:pt-8 pb-4 lg:pb-6">
|
||||||
<TimesheetHeader
|
<TimesheetHeader
|
||||||
|
:is-current-week="isCurrentWeek"
|
||||||
:week-number="weekNumber"
|
:week-number="weekNumber"
|
||||||
:range-label="weekRangeLabel"
|
:week-range-display="weekRangeDisplay"
|
||||||
:week-total-formatted="weekTotalFormatted"
|
:week-total-formatted="weekTotalFormatted"
|
||||||
@previous="goToPreviousWeek"
|
@previous="goToPreviousWeek"
|
||||||
@next="goToNextWeek"
|
@next="goToNextWeek"
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { Button } from '..';
|
||||||
|
import { ChevronLeft, ChevronRight } from '@lucide/vue';
|
||||||
import { Tabs, TabsList } from '../tabs';
|
import { Tabs, TabsList } from '../tabs';
|
||||||
import TabBarItem from '../TabBar/TabBarItem.vue';
|
import TabBarItem from '../TabBar/TabBarItem.vue';
|
||||||
import DateRangeNavigator from '../Input/DateRangeNavigator.vue';
|
|
||||||
import CalendarSettingsPopover from './CalendarSettingsPopover.vue';
|
import CalendarSettingsPopover from './CalendarSettingsPopover.vue';
|
||||||
import type { CalendarSettings } from './calendarSettings';
|
import type { CalendarSettings } from './calendarSettings';
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
rangeLabel: string;
|
viewTitle: string;
|
||||||
activeView: string;
|
activeView: string;
|
||||||
settings: CalendarSettings;
|
settings: CalendarSettings;
|
||||||
}>();
|
}>();
|
||||||
@@ -22,13 +23,31 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex items-center justify-between bg-default-background px-2 py-1.5">
|
<div class="flex items-center justify-between bg-default-background px-2 py-1.5">
|
||||||
<DateRangeNavigator
|
<!-- Left: Navigation -->
|
||||||
:label="rangeLabel"
|
<div class="flex items-center gap-1">
|
||||||
trigger-test-id="calendar-title"
|
<Button
|
||||||
trigger-aria-label="today"
|
variant="outline"
|
||||||
@previous="emit('prev')"
|
size="sm"
|
||||||
@next="emit('next')"
|
class="h-8 w-8 p-0"
|
||||||
@select="emit('today')" />
|
aria-label="Previous"
|
||||||
|
@click="emit('prev')">
|
||||||
|
<ChevronLeft class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
class="h-8 w-8 p-0"
|
||||||
|
aria-label="Next"
|
||||||
|
@click="emit('next')">
|
||||||
|
<ChevronRight class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" @click="emit('today')"> today </Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Center: Title -->
|
||||||
|
<span data-testid="calendar-title" class="text-base font-semibold text-foreground">{{
|
||||||
|
viewTitle
|
||||||
|
}}</span>
|
||||||
|
|
||||||
<!-- Right: View switcher + Settings -->
|
<!-- Right: View switcher + Settings -->
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ const {
|
|||||||
const {
|
const {
|
||||||
activeView,
|
activeView,
|
||||||
viewDays,
|
viewDays,
|
||||||
rangeLabel,
|
viewTitle,
|
||||||
emitDatesChange,
|
emitDatesChange,
|
||||||
handlePrev,
|
handlePrev,
|
||||||
handleNext,
|
handleNext,
|
||||||
@@ -514,7 +514,7 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
|
|||||||
|
|
||||||
<template v-if="!loading">
|
<template v-if="!loading">
|
||||||
<CalendarToolbar
|
<CalendarToolbar
|
||||||
:range-label="rangeLabel"
|
:view-title="viewTitle"
|
||||||
:active-view="activeView"
|
:active-view="activeView"
|
||||||
:settings="calendarSettings"
|
:settings="calendarSettings"
|
||||||
@prev="handlePrev"
|
@prev="handlePrev"
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { computed, ref } from 'vue';
|
|||||||
import type { Dayjs } from 'dayjs';
|
import type { Dayjs } from 'dayjs';
|
||||||
import { getLocalizedDayJs } from '../utils/time';
|
import { getLocalizedDayJs } from '../utils/time';
|
||||||
import { getWeekStartDayNumber } from '../utils/settings';
|
import { getWeekStartDayNumber } from '../utils/settings';
|
||||||
import { getDateRangeLabel } from '../utils/dateRange';
|
|
||||||
|
|
||||||
export function useCalendarNavigation(callbacks: {
|
export function useCalendarNavigation(callbacks: {
|
||||||
onDatesChange: (payload: { start: Dayjs; end: Dayjs }) => void;
|
onDatesChange: (payload: { start: Dayjs; end: Dayjs }) => void;
|
||||||
@@ -35,10 +34,24 @@ export function useCalendarNavigation(callbacks: {
|
|||||||
return days;
|
return days;
|
||||||
});
|
});
|
||||||
|
|
||||||
const rangeLabel = computed(() => {
|
const viewTitle = computed(() => {
|
||||||
|
if (activeView.value === 'timeGridDay') {
|
||||||
|
return currentDate.value.format('MMMM YYYY');
|
||||||
|
}
|
||||||
|
|
||||||
const days = viewDays.value;
|
const days = viewDays.value;
|
||||||
if (days.length === 0) return '';
|
if (days.length === 0) return '';
|
||||||
return getDateRangeLabel(days[0]!, days[days.length - 1]!);
|
|
||||||
|
const first = days[0]!;
|
||||||
|
const last = days[days.length - 1]!;
|
||||||
|
|
||||||
|
if (first.year() !== last.year()) {
|
||||||
|
return `${first.format('MMM YYYY')} \u2013 ${last.format('MMM YYYY')}`;
|
||||||
|
}
|
||||||
|
if (first.month() !== last.month()) {
|
||||||
|
return `${first.format('MMM')} \u2013 ${last.format('MMM YYYY')}`;
|
||||||
|
}
|
||||||
|
return first.format('MMMM YYYY');
|
||||||
});
|
});
|
||||||
|
|
||||||
function emitDatesChange() {
|
function emitDatesChange() {
|
||||||
@@ -86,7 +99,7 @@ export function useCalendarNavigation(callbacks: {
|
|||||||
activeView,
|
activeView,
|
||||||
currentDate,
|
currentDate,
|
||||||
viewDays,
|
viewDays,
|
||||||
rangeLabel,
|
viewTitle,
|
||||||
emitDatesChange,
|
emitDatesChange,
|
||||||
handlePrev,
|
handlePrev,
|
||||||
handleNext,
|
handleNext,
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ChevronLeft, ChevronRight, CalendarIcon } from '@lucide/vue';
|
|
||||||
import { Button } from '../Buttons';
|
|
||||||
|
|
||||||
withDefaults(
|
|
||||||
defineProps<{
|
|
||||||
label: string;
|
|
||||||
secondaryLabel?: string;
|
|
||||||
previousDisabled?: boolean;
|
|
||||||
nextDisabled?: boolean;
|
|
||||||
previousTestId?: string;
|
|
||||||
triggerTestId?: string;
|
|
||||||
nextTestId?: string;
|
|
||||||
triggerAriaLabel?: string;
|
|
||||||
}>(),
|
|
||||||
{
|
|
||||||
secondaryLabel: undefined,
|
|
||||||
previousTestId: undefined,
|
|
||||||
triggerTestId: undefined,
|
|
||||||
nextTestId: undefined,
|
|
||||||
triggerAriaLabel: undefined,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
previous: [];
|
|
||||||
next: [];
|
|
||||||
select: [];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const triggerClass =
|
|
||||||
'h-8 w-[13.5rem] justify-center rounded-none border-l border-r border-border-secondary px-2.5 text-text-primary tabular-nums hover:bg-card-background-active';
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
class="tabular-nums inline-flex items-stretch overflow-hidden rounded-md border border-input bg-card-background shadow-xs dark:bg-transparent"
|
|
||||||
role="group"
|
|
||||||
aria-label="Date range navigation">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
class="h-8 w-8 shrink-0 rounded-none hover:bg-card-background-active"
|
|
||||||
aria-label="Previous"
|
|
||||||
:disabled="previousDisabled"
|
|
||||||
:data-testid="previousTestId"
|
|
||||||
@click="emit('previous')">
|
|
||||||
<ChevronLeft class="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<slot name="trigger" :trigger-class="triggerClass">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
:class="triggerClass"
|
|
||||||
:aria-label="triggerAriaLabel"
|
|
||||||
:data-testid="triggerTestId"
|
|
||||||
@click="emit('select')">
|
|
||||||
<CalendarIcon class="h-4 w-4 shrink-0 text-icon-default" />
|
|
||||||
<span class="min-w-0 truncate">{{ label }}</span>
|
|
||||||
<span v-if="secondaryLabel" class="shrink-0 text-text-tertiary">
|
|
||||||
· {{ secondaryLabel }}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</slot>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
class="h-8 w-8 shrink-0 rounded-none hover:bg-card-background-active"
|
|
||||||
aria-label="Next"
|
|
||||||
:disabled="nextDisabled"
|
|
||||||
:data-testid="nextTestId"
|
|
||||||
@click="emit('next')">
|
|
||||||
<ChevronRight class="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -6,22 +6,22 @@ import { CalendarDate } from '@internationalized/date';
|
|||||||
import { CalendarIcon } from '@lucide/vue';
|
import { CalendarIcon } from '@lucide/vue';
|
||||||
import { computed, ref, inject, type ComputedRef, watch } from 'vue';
|
import { computed, ref, inject, type ComputedRef, watch } from 'vue';
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
import DateRangeNavigator from './DateRangeNavigator.vue';
|
import {
|
||||||
import { getLocalizedDayJs, firstDayIndex, type WeekStartDay } from '@/packages/ui/src/utils/time';
|
getDayJsInstance,
|
||||||
import { getDateRangeLabel, shiftDateRange, type DateRangeDirection } from '../utils/dateRange';
|
getLocalizedDayJs,
|
||||||
|
firstDayIndex,
|
||||||
|
type WeekStartDay,
|
||||||
|
} from '@/packages/ui/src/utils/time';
|
||||||
import { type Organization } from '@/packages/api/src';
|
import { type Organization } from '@/packages/api/src';
|
||||||
import { getUserTimezone } from '@/packages/ui/src/utils/settings';
|
import { getUserTimezone } from '@/packages/ui/src/utils/settings';
|
||||||
|
import { formatDate } from '@/packages/ui/src/utils/time';
|
||||||
|
|
||||||
const weekStartsOn = computed((): WeekStartDay => firstDayIndex.value as WeekStartDay);
|
const weekStartsOn = computed((): WeekStartDay => firstDayIndex.value as WeekStartDay);
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = defineProps<{
|
||||||
defineProps<{
|
start: string;
|
||||||
start: string;
|
end: string;
|
||||||
end: string;
|
}>();
|
||||||
allowFuture?: boolean;
|
|
||||||
}>(),
|
|
||||||
{ allowFuture: false }
|
|
||||||
);
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'update:start', value: string): void;
|
(e: 'update:start', value: string): void;
|
||||||
@@ -35,13 +35,10 @@ interface CalendarDateRange {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const today = computed(() => {
|
const today = computed(() => {
|
||||||
const now = getLocalizedDayJs();
|
const now = getDayJsInstance()();
|
||||||
return new CalendarDate(now.year(), now.month() + 1, now.date());
|
return new CalendarDate(now.year(), now.month() + 1, now.date());
|
||||||
});
|
});
|
||||||
|
|
||||||
const startDay = computed(() => (props.start ? getLocalizedDayJs(props.start) : undefined));
|
|
||||||
const endDay = computed(() => (props.end ? getLocalizedDayJs(props.end) : undefined));
|
|
||||||
|
|
||||||
const modelValue = computed<CalendarDateRange>({
|
const modelValue = computed<CalendarDateRange>({
|
||||||
get: () => ({
|
get: () => ({
|
||||||
start: props.start
|
start: props.start
|
||||||
@@ -94,7 +91,7 @@ function setLastWeek() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setLast14Days() {
|
function setLast14Days() {
|
||||||
emit('update:start', getLocalizedDayJs().subtract(13, 'days').startOf('day').format());
|
emit('update:start', getLocalizedDayJs().subtract(14, 'days').format());
|
||||||
emit('update:end', getLocalizedDayJs().format());
|
emit('update:end', getLocalizedDayJs().format());
|
||||||
open.value = false;
|
open.value = false;
|
||||||
}
|
}
|
||||||
@@ -112,14 +109,14 @@ function setLastMonth() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setLast30Days() {
|
function setLast30Days() {
|
||||||
emit('update:start', getLocalizedDayJs().subtract(29, 'days').startOf('day').format());
|
emit('update:start', getLocalizedDayJs().subtract(30, 'days').format());
|
||||||
emit('update:end', getLocalizedDayJs().format());
|
emit('update:end', getLocalizedDayJs().format());
|
||||||
open.value = false;
|
open.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setLast90Days() {
|
function setLast90Days() {
|
||||||
emit('update:start', getLocalizedDayJs().subtract(89, 'days').startOf('day').format());
|
emit('update:start', getDayJsInstance()().subtract(90, 'days').format());
|
||||||
emit('update:end', getLocalizedDayJs().format());
|
emit('update:end', getDayJsInstance()().format());
|
||||||
open.value = false;
|
open.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,38 +140,6 @@ function setLastYear() {
|
|||||||
|
|
||||||
const organization = inject<ComputedRef<Organization>>('organization');
|
const organization = inject<ComputedRef<Organization>>('organization');
|
||||||
|
|
||||||
const displayLabel = computed(() => {
|
|
||||||
if (!startDay.value) return 'Pick a date';
|
|
||||||
if (!endDay.value) {
|
|
||||||
return getDateRangeLabel(startDay.value, startDay.value, {
|
|
||||||
dateFormat: organization?.value?.date_format,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return getDateRangeLabel(startDay.value, endDay.value, {
|
|
||||||
dateFormat: organization?.value?.date_format,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const nextRange = computed(() => {
|
|
||||||
if (!startDay.value || !endDay.value) return undefined;
|
|
||||||
return shiftDateRange(startDay.value, endDay.value, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
const nextDisabled = computed(
|
|
||||||
() =>
|
|
||||||
!props.allowFuture &&
|
|
||||||
!!nextRange.value &&
|
|
||||||
nextRange.value.start.isAfter(getLocalizedDayJs().endOf('day'))
|
|
||||||
);
|
|
||||||
|
|
||||||
function navigate(direction: DateRangeDirection) {
|
|
||||||
if (!startDay.value || !endDay.value) return;
|
|
||||||
const shifted = shiftDateRange(startDay.value, endDay.value, direction);
|
|
||||||
emit('update:start', shifted.start.format());
|
|
||||||
emit('update:end', shifted.end.format());
|
|
||||||
emit('submit');
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(open, (value) => {
|
watch(open, (value) => {
|
||||||
if (value === false) {
|
if (value === false) {
|
||||||
emit('submit');
|
emit('submit');
|
||||||
@@ -183,120 +148,77 @@ watch(open, (value) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DateRangeNavigator
|
<Popover v-model:open="open">
|
||||||
:label="displayLabel"
|
<PopoverTrigger as-child>
|
||||||
:next-disabled="nextDisabled"
|
<Button
|
||||||
previous-test-id="date_range_picker_previous"
|
variant="outline"
|
||||||
trigger-test-id="date_range_picker_display"
|
:class="
|
||||||
next-test-id="date_range_picker_next"
|
twMerge(
|
||||||
@previous="navigate(-1)"
|
'flex w-full items-center justify-between whitespace-nowrap h-[34px] text-start',
|
||||||
@next="navigate(1)">
|
!modelValue && 'text-muted-foreground'
|
||||||
<template #trigger="{ triggerClass }">
|
)
|
||||||
<Popover v-model:open="open">
|
">
|
||||||
<PopoverTrigger as-child>
|
<CalendarIcon class="-ml-0.5 text-text-quaternary h-4 w-4" />
|
||||||
<Button
|
<template v-if="modelValue.start">
|
||||||
type="button"
|
<template v-if="modelValue.end">
|
||||||
variant="ghost"
|
{{ formatDate(modelValue.start.toString(), organization?.date_format) }}
|
||||||
size="sm"
|
-
|
||||||
:class="twMerge(triggerClass, !modelValue.start && 'text-muted-foreground')"
|
{{ formatDate(modelValue.end.toString(), organization?.date_format) }}
|
||||||
data-testid="date_range_picker_display">
|
</template>
|
||||||
<CalendarIcon class="text-text-quaternary h-4 w-4 shrink-0" />
|
<template v-else>
|
||||||
<span class="min-w-0 truncate">{{ displayLabel }}</span>
|
{{ formatDate(modelValue.start.toString(), organization?.date_format) }}
|
||||||
</Button>
|
</template>
|
||||||
</PopoverTrigger>
|
</template>
|
||||||
<PopoverContent class="w-auto p-0">
|
<template v-else> Pick a date </template>
|
||||||
<div class="flex divide-x divide-border-secondary">
|
</Button>
|
||||||
<div
|
</PopoverTrigger>
|
||||||
class="text-text-primary text-sm flex flex-col space-y-0.5 items-start py-2 px-2">
|
<PopoverContent class="w-auto p-0">
|
||||||
<Button
|
<div class="flex divide-x divide-border-secondary">
|
||||||
variant="ghost"
|
<div
|
||||||
size="sm"
|
class="text-text-primary text-sm flex flex-col space-y-0.5 items-start py-2 px-2">
|
||||||
class="justify-start"
|
<Button variant="ghost" size="sm" class="justify-start" @click="setToday"
|
||||||
@click="setToday"
|
>Today</Button
|
||||||
>Today</Button
|
>
|
||||||
>
|
<Button variant="ghost" size="sm" class="justify-start" @click="setThisWeek"
|
||||||
<Button
|
>This Week</Button
|
||||||
variant="ghost"
|
>
|
||||||
size="sm"
|
<Button variant="ghost" size="sm" class="justify-start" @click="setLastWeek"
|
||||||
class="justify-start"
|
>Last Week</Button
|
||||||
@click="setThisWeek"
|
>
|
||||||
>This Week</Button
|
<Button variant="ghost" size="sm" class="justify-start" @click="setLast14Days"
|
||||||
>
|
>Last 14 days</Button
|
||||||
<Button
|
>
|
||||||
variant="ghost"
|
<Button variant="ghost" size="sm" class="justify-start" @click="setThisMonth"
|
||||||
size="sm"
|
>This Month</Button
|
||||||
class="justify-start"
|
>
|
||||||
@click="setLastWeek"
|
<Button variant="ghost" size="sm" class="justify-start" @click="setLastMonth"
|
||||||
>Last Week</Button
|
>Last Month</Button
|
||||||
>
|
>
|
||||||
<Button
|
<Button variant="ghost" size="sm" class="justify-start" @click="setLast30Days"
|
||||||
variant="ghost"
|
>Last 30 days</Button
|
||||||
size="sm"
|
>
|
||||||
class="justify-start"
|
<Button variant="ghost" size="sm" class="justify-start" @click="setLast90Days"
|
||||||
@click="setLast14Days"
|
>Last 90 days</Button
|
||||||
>Last 14 Days</Button
|
>
|
||||||
>
|
<Button variant="ghost" size="sm" class="justify-start" @click="setLast12Months"
|
||||||
<Button
|
>Last 12 months</Button
|
||||||
variant="ghost"
|
>
|
||||||
size="sm"
|
<Button variant="ghost" size="sm" class="justify-start" @click="setThisYear"
|
||||||
class="justify-start"
|
>This year</Button
|
||||||
@click="setThisMonth"
|
>
|
||||||
>This Month</Button
|
<Button variant="ghost" size="sm" class="justify-start" @click="setLastYear"
|
||||||
>
|
>Last year</Button
|
||||||
<Button
|
>
|
||||||
variant="ghost"
|
</div>
|
||||||
size="sm"
|
<div class="pl-2">
|
||||||
class="justify-start"
|
<RangeCalendar
|
||||||
@click="setLastMonth"
|
v-model="modelValue"
|
||||||
>Last Month</Button
|
initial-focus
|
||||||
>
|
:number-of-months="2"
|
||||||
<Button
|
:max-value="today"
|
||||||
variant="ghost"
|
:week-starts-on="weekStartsOn" />
|
||||||
size="sm"
|
</div>
|
||||||
class="justify-start"
|
</div>
|
||||||
@click="setLast30Days"
|
</PopoverContent>
|
||||||
>Last 30 Days</Button
|
</Popover>
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
class="justify-start"
|
|
||||||
@click="setLast90Days"
|
|
||||||
>Last 90 Days</Button
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
class="justify-start"
|
|
||||||
@click="setLast12Months"
|
|
||||||
>Last 12 Months</Button
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
class="justify-start"
|
|
||||||
@click="setThisYear"
|
|
||||||
>This Year</Button
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
class="justify-start"
|
|
||||||
@click="setLastYear"
|
|
||||||
>Last Year</Button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="pl-2">
|
|
||||||
<RangeCalendar
|
|
||||||
v-model="modelValue"
|
|
||||||
initial-focus
|
|
||||||
:number-of-months="2"
|
|
||||||
:max-value="allowFuture ? undefined : today"
|
|
||||||
:week-starts-on="weekStartsOn" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</template>
|
|
||||||
</DateRangeNavigator>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -132,7 +132,6 @@ import FullCalendarDayHeader from './FullCalendar/FullCalendarDayHeader.vue';
|
|||||||
import FullCalendarEventContent from './FullCalendar/FullCalendarEventContent.vue';
|
import FullCalendarEventContent from './FullCalendar/FullCalendarEventContent.vue';
|
||||||
import TimeEntryCalendar from './FullCalendar/TimeEntryCalendar.vue';
|
import TimeEntryCalendar from './FullCalendar/TimeEntryCalendar.vue';
|
||||||
import DateRangePicker from './Input/DateRangePicker.vue';
|
import DateRangePicker from './Input/DateRangePicker.vue';
|
||||||
import DateRangeNavigator from './Input/DateRangeNavigator.vue';
|
|
||||||
import { Label } from './label/index';
|
import { Label } from './label/index';
|
||||||
import {
|
import {
|
||||||
NumberField,
|
NumberField,
|
||||||
@@ -212,7 +211,6 @@ export {
|
|||||||
ContextMenuSubTrigger,
|
ContextMenuSubTrigger,
|
||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
DateRangePicker,
|
DateRangePicker,
|
||||||
DateRangeNavigator,
|
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogClose,
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
import dayjs from 'dayjs';
|
|
||||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
|
||||||
import { getDayJsInstance, getLocalizedDayJs } from './time';
|
|
||||||
import { getDateRangeLabel, shiftDateRange } from './dateRange';
|
|
||||||
|
|
||||||
const reference = dayjs('2026-09-21');
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.mocked(window.getTimezoneSetting).mockReturnValue('UTC');
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getDateRangeLabel', () => {
|
|
||||||
test('labels relative weeks', () => {
|
|
||||||
getDayJsInstance();
|
|
||||||
expect(
|
|
||||||
getDateRangeLabel(reference.startOf('week'), reference.endOf('week'), {
|
|
||||||
referenceDate: reference,
|
|
||||||
})
|
|
||||||
).toBe('This Week');
|
|
||||||
expect(
|
|
||||||
getDateRangeLabel(
|
|
||||||
reference.subtract(1, 'week').startOf('week'),
|
|
||||||
reference.subtract(1, 'week').endOf('week'),
|
|
||||||
{ referenceDate: reference }
|
|
||||||
)
|
|
||||||
).toBe('Last Week');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('labels common rolling ranges', () => {
|
|
||||||
expect(
|
|
||||||
getDateRangeLabel(reference.subtract(13, 'day'), reference.endOf('day'), {
|
|
||||||
referenceDate: reference,
|
|
||||||
})
|
|
||||||
).toBe('Last 14 Days');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('labels full calendar months and years', () => {
|
|
||||||
expect(
|
|
||||||
getDateRangeLabel(dayjs('2025-09-01'), dayjs('2025-09-30'), {
|
|
||||||
referenceDate: reference,
|
|
||||||
})
|
|
||||||
).toBe('September 2025');
|
|
||||||
expect(
|
|
||||||
getDateRangeLabel(dayjs('2024-01-01'), dayjs('2024-12-31'), {
|
|
||||||
referenceDate: reference,
|
|
||||||
})
|
|
||||||
).toBe('2024');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('keeps relative labels for the current periods', () => {
|
|
||||||
expect(
|
|
||||||
getDateRangeLabel(reference.startOf('month'), reference.endOf('month'), {
|
|
||||||
referenceDate: reference,
|
|
||||||
})
|
|
||||||
).toBe('This Month');
|
|
||||||
expect(
|
|
||||||
getDateRangeLabel(reference.startOf('year'), reference.endOf('year'), {
|
|
||||||
referenceDate: reference,
|
|
||||||
})
|
|
||||||
).toBe('This Year');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('uses the organization date format for custom ranges', () => {
|
|
||||||
expect(
|
|
||||||
getDateRangeLabel(dayjs('2026-08-03'), dayjs('2026-08-12'), {
|
|
||||||
dateFormat: 'slash-separated-dd-mm-yyyy',
|
|
||||||
referenceDate: reference,
|
|
||||||
})
|
|
||||||
).toBe('03/08/2026 – 12/08/2026');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('renders a single historical day without duplicating it', () => {
|
|
||||||
expect(
|
|
||||||
getDateRangeLabel(dayjs('2026-08-03'), dayjs('2026-08-03'), {
|
|
||||||
referenceDate: reference,
|
|
||||||
})
|
|
||||||
).toBe('Aug 3, 2026');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('shiftDateRange', () => {
|
|
||||||
test('moves full weeks as calendar periods', () => {
|
|
||||||
getDayJsInstance();
|
|
||||||
const shifted = shiftDateRange(reference.startOf('week'), reference.endOf('week'), -1);
|
|
||||||
expect(shifted.start.format('YYYY-MM-DD')).toBe('2026-09-14');
|
|
||||||
expect(shifted.end.format('YYYY-MM-DD')).toBe('2026-09-20');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('moves full months without truncating longer months', () => {
|
|
||||||
const shifted = shiftDateRange(dayjs('2026-02-01'), dayjs('2026-02-28'), 1);
|
|
||||||
expect(shifted.start.format('YYYY-MM-DD')).toBe('2026-03-01');
|
|
||||||
expect(shifted.end.format('YYYY-MM-DD')).toBe('2026-03-31');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('moves custom ranges by their inclusive duration', () => {
|
|
||||||
const shifted = shiftDateRange(dayjs('2026-09-01'), dayjs('2026-09-14'), -1);
|
|
||||||
expect(shifted.start.format('YYYY-MM-DD')).toBe('2026-08-18');
|
|
||||||
expect(shifted.end.format('YYYY-MM-DD')).toBe('2026-08-31');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('normalizes custom range offsets across the spring DST transition', () => {
|
|
||||||
vi.mocked(window.getTimezoneSetting).mockReturnValue('Europe/Vienna');
|
|
||||||
const shifted = shiftDateRange(
|
|
||||||
getLocalizedDayJs('2026-03-14T23:00:00Z'),
|
|
||||||
getLocalizedDayJs('2026-03-27T23:00:00Z'),
|
|
||||||
1
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(shifted.start.format()).toBe('2026-03-29T00:00:00+01:00');
|
|
||||||
expect(shifted.end.format()).toBe('2026-04-11T23:59:59+02:00');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('normalizes custom range offsets across the autumn DST transition', () => {
|
|
||||||
vi.mocked(window.getTimezoneSetting).mockReturnValue('Europe/Vienna');
|
|
||||||
const shifted = shiftDateRange(
|
|
||||||
getLocalizedDayJs('2026-10-17T22:00:00Z'),
|
|
||||||
getLocalizedDayJs('2026-10-30T23:00:00Z'),
|
|
||||||
1
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(shifted.start.format()).toBe('2026-11-01T00:00:00+01:00');
|
|
||||||
expect(shifted.end.format()).toBe('2026-11-14T23:59:59+01:00');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
import type { Dayjs } from 'dayjs';
|
|
||||||
import { formatDate, getDayJsInstance, getLocalizedDayJs, type DateFormat } from './time';
|
|
||||||
|
|
||||||
export type DateRangeDirection = -1 | 1;
|
|
||||||
|
|
||||||
function isSameDay(left: Dayjs, right: Dayjs): boolean {
|
|
||||||
return left.isSame(right, 'day');
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRange(start: Dayjs, end: Dayjs, expectedStart: Dayjs, expectedEnd: Dayjs): boolean {
|
|
||||||
return isSameDay(start, expectedStart) && isSameDay(end, expectedEnd);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatCompactRange(start: Dayjs, end: Dayjs): string {
|
|
||||||
if (start.year() !== end.year()) {
|
|
||||||
return `${start.format('MMM D, YYYY')} – ${end.format('MMM D, YYYY')}`;
|
|
||||||
}
|
|
||||||
if (start.month() !== end.month()) {
|
|
||||||
return `${start.format('MMM D')} – ${end.format('MMM D, YYYY')}`;
|
|
||||||
}
|
|
||||||
return `${start.format('MMM D')} – ${end.format('D, YYYY')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a human-friendly label for an inclusive date range. Relative labels
|
|
||||||
* are intentionally shared by reporting, the timesheet, and the calendar.
|
|
||||||
*/
|
|
||||||
export function getDateRangeLabel(
|
|
||||||
start: Dayjs,
|
|
||||||
end: Dayjs,
|
|
||||||
options: { dateFormat?: DateFormat; referenceDate?: Dayjs } = {}
|
|
||||||
): string {
|
|
||||||
// Ensure the configured first day of the week is applied before using startOf('week').
|
|
||||||
getDayJsInstance();
|
|
||||||
|
|
||||||
const reference = (options.referenceDate ?? getLocalizedDayJs()).startOf('day');
|
|
||||||
const rangeStart = start.startOf('day');
|
|
||||||
const rangeEnd = end.startOf('day');
|
|
||||||
|
|
||||||
const relativeDays: Array<[number, string]> = [
|
|
||||||
[0, 'Today'],
|
|
||||||
[-1, 'Yesterday'],
|
|
||||||
[1, 'Tomorrow'],
|
|
||||||
];
|
|
||||||
for (const [offset, label] of relativeDays) {
|
|
||||||
const day = reference.add(offset, 'day');
|
|
||||||
if (isRange(rangeStart, rangeEnd, day, day)) return label;
|
|
||||||
}
|
|
||||||
|
|
||||||
const relativePeriods: Array<['week' | 'month' | 'year', number, string]> = [
|
|
||||||
['week', 0, 'This Week'],
|
|
||||||
['week', -1, 'Last Week'],
|
|
||||||
['week', 1, 'Next Week'],
|
|
||||||
['month', 0, 'This Month'],
|
|
||||||
['month', -1, 'Last Month'],
|
|
||||||
['month', 1, 'Next Month'],
|
|
||||||
['year', 0, 'This Year'],
|
|
||||||
['year', -1, 'Last Year'],
|
|
||||||
['year', 1, 'Next Year'],
|
|
||||||
];
|
|
||||||
for (const [unit, offset, label] of relativePeriods) {
|
|
||||||
const period = reference.add(offset, unit);
|
|
||||||
if (isRange(rangeStart, rangeEnd, period.startOf(unit), period.endOf(unit))) return label;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Named calendar periods outside the relative windows above, e.g. "May 2025"
|
|
||||||
// for a full month and "2025" for a full year.
|
|
||||||
const monthStart = rangeStart.startOf('month');
|
|
||||||
if (isRange(rangeStart, rangeEnd, monthStart, monthStart.endOf('month'))) {
|
|
||||||
return rangeStart.format('MMMM YYYY');
|
|
||||||
}
|
|
||||||
const yearStart = rangeStart.startOf('year');
|
|
||||||
if (isRange(rangeStart, rangeEnd, yearStart, yearStart.endOf('year'))) {
|
|
||||||
return rangeStart.format('YYYY');
|
|
||||||
}
|
|
||||||
|
|
||||||
const rollingRanges: Array<[number, string]> = [
|
|
||||||
[14, 'Last 14 Days'],
|
|
||||||
[30, 'Last 30 Days'],
|
|
||||||
[90, 'Last 90 Days'],
|
|
||||||
];
|
|
||||||
for (const [days, label] of rollingRanges) {
|
|
||||||
if (isRange(rangeStart, rangeEnd, reference.subtract(days - 1, 'day'), reference)) {
|
|
||||||
return label;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (isRange(rangeStart, rangeEnd, reference.subtract(12, 'month'), reference)) {
|
|
||||||
return 'Last 12 Months';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isSameDay(rangeStart, rangeEnd)) {
|
|
||||||
return options.dateFormat
|
|
||||||
? formatDate(rangeStart.format('YYYY-MM-DD'), options.dateFormat)
|
|
||||||
: rangeStart.format('MMM D, YYYY');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.dateFormat) {
|
|
||||||
return `${formatDate(rangeStart.format('YYYY-MM-DD'), options.dateFormat)} – ${formatDate(
|
|
||||||
rangeEnd.format('YYYY-MM-DD'),
|
|
||||||
options.dateFormat
|
|
||||||
)}`;
|
|
||||||
}
|
|
||||||
return formatCompactRange(rangeStart, rangeEnd);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Shift an inclusive range by one period, preserving full calendar periods. */
|
|
||||||
export function shiftDateRange(
|
|
||||||
start: Dayjs,
|
|
||||||
end: Dayjs,
|
|
||||||
direction: DateRangeDirection
|
|
||||||
): { start: Dayjs; end: Dayjs } {
|
|
||||||
getDayJsInstance();
|
|
||||||
|
|
||||||
const rangeStart = start.startOf('day');
|
|
||||||
const rangeEnd = end.endOf('day');
|
|
||||||
|
|
||||||
if (isRange(rangeStart, rangeEnd, rangeStart.startOf('year'), rangeStart.endOf('year'))) {
|
|
||||||
const shiftedStart = rangeStart.add(direction, 'year').startOf('year');
|
|
||||||
return { start: shiftedStart, end: shiftedStart.endOf('year') };
|
|
||||||
}
|
|
||||||
if (isRange(rangeStart, rangeEnd, rangeStart.startOf('month'), rangeStart.endOf('month'))) {
|
|
||||||
const shiftedStart = rangeStart.add(direction, 'month').startOf('month');
|
|
||||||
return { start: shiftedStart, end: shiftedStart.endOf('month') };
|
|
||||||
}
|
|
||||||
if (isRange(rangeStart, rangeEnd, rangeStart.startOf('week'), rangeStart.endOf('week'))) {
|
|
||||||
const shiftedStart = rangeStart.add(direction, 'week').startOf('week');
|
|
||||||
return { start: shiftedStart, end: shiftedStart.endOf('week') };
|
|
||||||
}
|
|
||||||
|
|
||||||
const days = rangeEnd.startOf('day').diff(rangeStart, 'day') + 1;
|
|
||||||
return {
|
|
||||||
start: rangeStart.add(days * direction, 'day').startOf('day'),
|
|
||||||
end: rangeEnd.add(days * direction, 'day').endOf('day'),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user