Compare commits

...

11 Commits

Author SHA1 Message Date
Gregor Vostrak
595132ec2c fix diagnostics smoke test 2026-05-12 15:15:40 +02:00
Gregor Vostrak
0659cb3993 fix smoke test permissions 2026-05-11 20:15:21 +02:00
Gregor Vostrak
e8fc6fd77e removed SOLIDTIME_DROP_PRIVILEGES always option 2026-05-11 20:10:37 +02:00
Gregor Vostrak
a04185921d improve self-hosting permission handling 2026-05-11 19:08:55 +02:00
Gregor Vostrak
b73aa543fd Merge commit from fork 2026-04-21 21:12:30 +02:00
Gregor Vostrak
2d6f9e514f add groupSimilarTimeEntries to TimeEntryGroupedTable 2026-04-21 20:44:33 +02:00
Gregor Vostrak
f8e668790b Fix typo in project name in README.md 2026-04-18 04:27:50 +02:00
utlark
77a5e979c6 Added the ability to disable group similar time entries (#1054)
* Added the ability to disable group similar time entries

* Fix E2E test for Group similar time entries

* Simplify `TimeEntryGroupedTable` by replacing ternary with early return logic

* Refactor time entry grouping settings: rename storage key, move logic into a dedicated module

* Replace fixed `waitForTimeout` calls in E2E tests with element-based waits and assertions

* Run frontend linting and formatting for changes
2026-04-17 16:44:59 +02:00
Gregor Vostrak
353a579850 chore: bump ui package version 2026-04-17 14:46:36 +02:00
Gregor Vostrak
bd44a2b376 fix e2e tests for new duration reporting format logic 2026-04-17 14:36:56 +02:00
Gregor Vostrak
277dbaf6eb promote duration formats that omit seconds to HH:mm:ss in reporting
views and exports
2026-04-17 12:15:26 +02:00
39 changed files with 837 additions and 83 deletions

258
.github/workflows/image-smoke-test.yml vendored Normal file
View File

@@ -0,0 +1,258 @@
name: Image Smoke Tests
on:
pull_request:
paths:
- 'docker/prod/**'
- '.github/workflows/image-smoke-test.yml'
workflow_dispatch:
permissions:
contents: read
jobs:
smoke:
name: Smoke (${{ matrix.mode }})
runs-on: ubuntu-24.04
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
mode:
- default
- puid-pgid
- openshift
- drop-never
- diagnostic
- puid-mismatch-warning
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Copy .env template
run: |
cp .env.production .env
rm .env.production .env.ci .env.example
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: mbstring, dom, fileinfo, pgsql
- name: Composer install
run: composer install --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20.x'
- name: NPM ci
run: npm ci
- name: NPM build
run: npm run build
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build smoke image
uses: docker/build-push-action@v6
with:
context: .
file: docker/prod/Dockerfile
build-args: |
DOCKER_FILES_BASE_PATH=docker/prod/
load: true
tags: solidtime-smoke:test
cache-from: type=gha
cache-to: type=gha,mode=max
- name: "Smoke: default (image config + fresh deploy with empty bind mounts)"
if: matrix.mode == 'default'
run: |
echo "[smoke] image's default USER is root (entrypoint needs root to drop privs)"
user=$(docker inspect --format '{{.Config.User}}' solidtime-smoke:test)
if [ "$user" != "root" ]; then
echo "Expected 'root', got '$user'. The Dockerfile must end with USER root so the entrypoint can chown/usermod and drop privileges."
exit 1
fi
echo "[smoke] storage tree is group-0 owned (OpenShift / arbitrary-UID compat)"
group=$(docker run --rm --entrypoint stat solidtime-smoke:test -c '%g' /var/www/html/storage)
if [ "$group" != "0" ]; then
echo "Expected group 0, got '$group'. The Dockerfile must chgrp -R 0 storage bootstrap/cache so arbitrary-UID containers can write."
exit 1
fi
mkdir -p test-storage test-cache
docker run --rm \
-v "$(pwd)/test-storage:/var/www/html/storage" \
-v "$(pwd)/test-cache:/var/www/html/bootstrap/cache" \
solidtime-smoke:test \
sh -c '
set -e
echo "[smoke] framework subdirs exist"
test -d /var/www/html/storage/framework/cache/data
test -d /var/www/html/storage/framework/sessions
test -d /var/www/html/storage/framework/views
test -d /var/www/html/storage/framework/testing
test -d /var/www/html/storage/logs
test -d /var/www/html/storage/app/public
test -d /var/www/html/storage/app/private
test -d /var/www/html/bootstrap/cache
echo "[smoke] storage is writable"
touch /var/www/html/storage/framework/cache/data/test-file
echo "[smoke] running as octane (UID 1000)"
[ "$(id -u)" = "1000" ]
echo "[smoke] PASS"
'
- name: "Smoke: PUID/PGID remap"
if: matrix.mode == 'puid-pgid'
run: |
mkdir -p test-storage test-cache
sudo chown -R 1501:1501 test-storage test-cache
docker run --rm \
-e PUID=1501 -e PGID=1501 \
-v "$(pwd)/test-storage:/var/www/html/storage" \
-v "$(pwd)/test-cache:/var/www/html/bootstrap/cache" \
solidtime-smoke:test \
sh -c '
set -e
echo "[smoke] running as remapped UID/GID 1501"
[ "$(id -u)" = "1501" ]
[ "$(id -g)" = "1501" ]
echo "[smoke] storage is writable as 1501"
touch /var/www/html/storage/framework/cache/data/test-file
echo "[smoke] PASS"
'
- name: "Smoke: OpenShift / arbitrary UID + group 0"
if: matrix.mode == 'openshift'
run: |
mkdir -p test-storage test-cache
sudo chown -R 2000:0 test-storage test-cache
sudo chmod -R g+rwX test-storage test-cache
docker run --rm --user 2000:0 \
-v "$(pwd)/test-storage:/var/www/html/storage" \
-v "$(pwd)/test-cache:/var/www/html/bootstrap/cache" \
solidtime-smoke:test \
sh -c '
set -e
echo "[smoke] running as arbitrary UID 2000, group 0"
[ "$(id -u)" = "2000" ]
[ "$(id -g)" = "0" ]
echo "[smoke] storage is writable via group 0"
touch /var/www/html/storage/framework/cache/data/test-file
echo "[smoke] PASS"
'
- name: "Smoke: SOLIDTIME_DROP_PRIVILEGES=never (run as root)"
if: matrix.mode == 'drop-never'
run: |
mkdir -p test-storage test-cache
docker run --rm \
-e SOLIDTIME_DROP_PRIVILEGES=never \
-v "$(pwd)/test-storage:/var/www/html/storage" \
-v "$(pwd)/test-cache:/var/www/html/bootstrap/cache" \
solidtime-smoke:test \
sh -c '
set -e
echo "[smoke] running as root (privilege drop disabled)"
[ "$(id -u)" = "0" ]
echo "[smoke] bootstrap still ran"
test -d /var/www/html/storage/framework/cache/data
echo "[smoke] storage writable as root"
touch /var/www/html/storage/framework/cache/data/test-file
echo "[smoke] PASS"
'
- name: "Smoke: PUID set + started non-root prints a warning but continues"
if: matrix.mode == 'puid-mismatch-warning'
run: |
mkdir -p test-storage test-cache
sudo chown -R 1500:1500 test-storage test-cache
set +e
docker run --rm \
--user 1500:1500 \
-e PUID=1500 -e PGID=1500 \
-v "$(pwd)/test-storage:/var/www/html/storage" \
-v "$(pwd)/test-cache:/var/www/html/bootstrap/cache" \
solidtime-smoke:test \
sh -c '
set -e
echo "[smoke] running as 1500 (user: directive wins)"
[ "$(id -u)" = "1500" ]
echo "[smoke] storage is writable as 1500"
touch /var/www/html/storage/framework/cache/data/test-file
echo "[smoke] container completed successfully"
' \
>stdout.log 2>stderr.log
exit_code=$?
set -e
echo "[smoke] exit code: $exit_code"
echo "--- stderr ---"
cat stderr.log
echo "--- end stderr ---"
if [ "$exit_code" -ne 0 ]; then
echo "Expected the entrypoint to continue (warning is non-fatal)."
exit 1
fi
for needle in "PUID/PGID is set but the container started as UID" "remove any 'user:' directive" "Continuing as UID"; do
if ! grep -q "$needle" stderr.log; then
echo "Missing warning fragment: $needle"
exit 1
fi
done
echo "[smoke] PASS"
- name: "Smoke: diagnostic error path (read-only storage mount)"
if: matrix.mode == 'diagnostic'
run: |
# Pre-create the full storage tree on the host so the entrypoint's
# bootstrap_storage_tree() is a no-op (mkdir -p on existing dirs
# returns 0 even on a read-only mount). The write test then fires
# against the RO mount and triggers our diagnostic.
mkdir -p test-storage/framework/cache/data \
test-storage/framework/sessions \
test-storage/framework/views \
test-storage/framework/testing \
test-storage/logs \
test-storage/app/public \
test-storage/app/private \
test-cache
set +e
docker run --rm \
-v "$(pwd)/test-storage:/var/www/html/storage:ro" \
-v "$(pwd)/test-cache:/var/www/html/bootstrap/cache:ro" \
solidtime-smoke:test \
true \
>stdout.log 2>stderr.log
exit_code=$?
set -e
echo "[smoke] exit code: $exit_code"
echo "--- stderr ---"
cat stderr.log
echo "--- end stderr ---"
if [ "$exit_code" -eq 0 ]; then
echo "Expected the entrypoint to exit non-zero on an unwritable storage mount."
exit 1
fi
for needle in "not writable" "PUID=" "permissions"; do
if ! grep -q "$needle" stderr.log; then
echo "Missing diagnostic fragment: $needle"
exit 1
fi
done
echo "[smoke] PASS"

View File

@@ -1,4 +1,4 @@
# solidtime - The modern Open-Source Time Tracker
# solidtime - The modern Open-Source TimeTracker
[![GitHub License](https://img.shields.io/github/license/solidtime-io/solidtime?style=flat-square)](https://github.com/solidtime-io/solidtime/blob/main/LICENSE.md)
[![Codecov](https://img.shields.io/codecov/c/github/solidtime-io/solidtime?style=flat-square&logo=codecov)](https://codecov.io/gh/solidtime-io/solidtime)

View File

@@ -629,9 +629,9 @@ class TimeEntryController extends Controller
/** @var Member|null $member */
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
if ($timeEntry->member->user_id === Auth::id() && ($member === null || $member->user_id === Auth::id())) {
$this->checkPermission($organization, 'time-entries:update:own');
$this->checkPermission($organization, 'time-entries:update:own', $timeEntry);
} else {
$this->checkPermission($organization, 'time-entries:update:all');
$this->checkPermission($organization, 'time-entries:update:all', $timeEntry);
}
if ($timeEntry->end !== null && $request->has('end') && $request->input('end') === null) {

View File

@@ -96,6 +96,30 @@ class LocalizationService
}
}
/**
* Format a duration for reporting contexts (PDF reports, places that display duration
* directly next to cost). Promotes the verbose `Hh Mm` format to the compact `HH:MM:SS`
* so totals stay narrow and reconcile with cost, which is always computed to the second.
*/
public function formatIntervalForReporting(CarbonInterval $interval): string
{
$promoted = [
IntervalFormat::HoursMinutes,
IntervalFormat::HoursMinutesColonSeparated,
];
if (! in_array($this->intervalFormat, $promoted, true)) {
return $this->formatInterval($interval);
}
$previous = $this->intervalFormat;
$this->intervalFormat = IntervalFormat::HoursMinutesSecondsColonSeparated;
try {
return $this->formatInterval($interval);
} finally {
$this->intervalFormat = $previous;
}
}
public function formatCurrency(Money $money): string
{
$currencyService = app(CurrencyService::class);

View File

@@ -68,6 +68,7 @@ RUN apt-get update; \
wget \
vim \
git \
gosu \
ncdu \
procps \
unzip \
@@ -193,9 +194,20 @@ COPY --link --chown=${WWWUSER}:${WWWUSER} . .
#COPY --link --chown=${WWWUSER}:${WWWUSER} --from=build ${ROOT}/public public
RUN mkdir -p \
storage/framework/{sessions,views,cache,testing} \
storage/framework/{sessions,views,cache/data,testing} \
storage/logs \
bootstrap/cache && chmod -R a+rw storage
storage/app/public \
storage/app/private \
bootstrap/cache && \
ln -s ../storage/app/public public/storage && \
chmod -R a+rw storage bootstrap/cache
# OpenShift / arbitrary-UID compatibility: group 0 (root group) gets read+write+execute
# on writable paths. Any UID can run the container if it joins the root group.
# https://docs.openshift.com/container-platform/latest/openshift_images/create-images.html
USER root
RUN chgrp -R 0 storage bootstrap/cache && \
chmod -R g+rwX storage bootstrap/cache
#RUN composer install \
# --classmap-authoritative \

View File

@@ -1,7 +1,6 @@
[program:octane]
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
user = %(ENV_USER)s
priority = 1
autostart = true
autorestart = true
@@ -14,7 +13,6 @@ 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
@@ -27,7 +25,6 @@ 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
@@ -38,7 +35,6 @@ 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
@@ -51,7 +47,6 @@ 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

View File

@@ -1,6 +1,240 @@
#!/usr/bin/env sh
#!/bin/bash
set -e
# ============================================================================
# Solidtime container entrypoint.
#
# Layout:
# 1. Storage tree bootstrap (idempotent, runs as any user)
# 2. UID/GID remap + chown (root only, controlled by PUID/PGID env vars)
# 3. Pre-flight write test (fails fast with a diagnosis message)
# 4. Privilege drop via gosu, then re-exec self as APP_USER
# 5. Original CONTAINER_MODE routing (runs as APP_USER)
#
# Env vars:
# PUID, PGID UID/GID for the application user. Defaults 1000:1000.
# Only takes effect when the container starts as root
# (which is the image's default — if you set a
# `user:` directive in compose, PUID/PGID are ignored
# and a startup warning is printed).
# SOLIDTIME_DROP_PRIVILEGES auto (default) | never
# auto: if started as root, drop privileges to APP_USER; otherwise just exec.
# never: never drop privileges. Run as whatever UID/GID was started.
# ============================================================================
APP_USER="octane"
APP_PATH="${ROOT:-/var/www/html}"
STORAGE_PATH="${APP_PATH}/storage"
CACHE_PATH="${APP_PATH}/bootstrap/cache"
DEFAULT_UID=1000
DEFAULT_GID=1000
TARGET_UID="${PUID:-${DEFAULT_UID}}"
TARGET_GID="${PGID:-${DEFAULT_GID}}"
DROP_PRIVS="${SOLIDTIME_DROP_PRIVILEGES:-auto}"
WRITABLE_PATHS=(
"${STORAGE_PATH}/framework/cache/data"
"${STORAGE_PATH}/framework/sessions"
"${STORAGE_PATH}/framework/views"
"${STORAGE_PATH}/framework/testing"
"${STORAGE_PATH}/logs"
"${STORAGE_PATH}/app/public"
"${STORAGE_PATH}/app/private"
"${CACHE_PATH}"
)
case "${DROP_PRIVS}" in
never) SHOULD_DROP=0 ;;
auto)
if [ "$(id -u)" = "0" ]; then
SHOULD_DROP=1
else
SHOULD_DROP=0
fi
;;
*)
echo "[entrypoint] ERROR: invalid SOLIDTIME_DROP_PRIVILEGES='${DROP_PRIVS}'" >&2
echo "[entrypoint] Valid values: auto (default), never" >&2
exit 1
;;
esac
# Warn if PUID/PGID are set but the container started non-root. PUID/PGID only
# take effect during the drop-privileges flow, which requires starting as root.
# A common cause is leaving `user:` in the compose file alongside PUID env vars.
if { [ -n "${PUID}" ] || [ -n "${PGID}" ]; } \
&& [ "$(id -u)" != "0" ] \
&& [ "${SOLIDTIME_PRIVILEGES_DROPPED:-0}" != "1" ]; then
cat >&2 <<EOF
[entrypoint] WARNING: PUID/PGID is set but the container started as UID $(id -u) (not root).
[entrypoint] WARNING: PUID/PGID only apply when the entrypoint runs as root and drops privileges.
[entrypoint] WARNING:
[entrypoint] WARNING: To use PUID/PGID: remove any 'user:' directive from your compose file.
[entrypoint] WARNING: To run as a fixed UID: remove PUID/PGID from your env.
[entrypoint] WARNING:
[entrypoint] WARNING: Continuing as UID $(id -u). See:
[entrypoint] WARNING: https://docs.solidtime.io/self-hosting/guides/permissions
EOF
fi
bootstrap_storage_tree() {
mkdir -p "${WRITABLE_PATHS[@]}" 2>/dev/null || return 1
}
# Proactive warning when the existing storage directory is owned by a non-default
# UID (typical on NAS systems where host users aren't UID 1000) and PUID/PGID
# aren't set. Without this nudge, the chown step silently re-owns the files to
# 1000:1000 and the user only discovers the mismatch later when host-side tools
# (backup, file browser, rsync) show unfamiliar ownership.
maybe_warn_ownership_mismatch() {
[ "${SHOULD_DROP}" = "1" ] || return 0
[ -n "${PUID}" ] && return 0
[ -n "${PGID}" ] && return 0
[ -d "${STORAGE_PATH}" ] || return 0
local owner_uid owner_gid
owner_uid="$(stat -c '%u' "${STORAGE_PATH}" 2>/dev/null)" || return 0
owner_gid="$(stat -c '%g' "${STORAGE_PATH}" 2>/dev/null)" || return 0
# Root-owned: probably freshly created by the entrypoint, will be chowned shortly.
[ "${owner_uid}" = "0" ] && return 0
# Already the target: nothing to warn about.
[ "${owner_uid}" = "${TARGET_UID}" ] && return 0
cat >&2 <<EOF
[entrypoint] NOTE: ${STORAGE_PATH} is owned by UID ${owner_uid}:${owner_gid},
[entrypoint] but the container is starting as UID ${TARGET_UID}:${TARGET_GID}.
[entrypoint] Files will be chowned to ${TARGET_UID}:${TARGET_GID} and may
[entrypoint] appear with an unfamiliar owner on the host.
[entrypoint]
[entrypoint] If you want the container to write as UID ${owner_uid} (common
[entrypoint] on Synology / TrueNAS / Unraid where host users aren't UID
[entrypoint] 1000), set in your env and restart:
[entrypoint]
[entrypoint] PUID=${owner_uid}
[entrypoint] PGID=${owner_gid}
[entrypoint]
[entrypoint] More: https://docs.solidtime.io/self-hosting/guides/permissions
EOF
}
print_write_test_failure() {
local owner
owner="$(stat -c '%u:%g' "${STORAGE_PATH}" 2>/dev/null || echo unknown)"
local runtime_uid
local runtime_gid
if [ "$(id -u)" = "0" ] && [ "${SHOULD_DROP}" = "1" ]; then
runtime_uid="${TARGET_UID}"
runtime_gid="${TARGET_GID}"
else
runtime_uid="$(id -u)"
runtime_gid="$(id -g)"
fi
local owner_uid
owner_uid="$(stat -c '%u' "${STORAGE_PATH}" 2>/dev/null || echo 1000)"
local owner_gid
owner_gid="$(stat -c '%g' "${STORAGE_PATH}" 2>/dev/null || echo 1000)"
cat >&2 <<EOF
============================================================
ERROR: Solidtime writable directories are not writable.
Diagnosis:
Container will run as: UID ${runtime_uid}, GID ${runtime_gid}
Storage directory owner: ${owner}
Likely cause: a bind-mounted host directory is owned by a different
user than the container's application user.
Fix on the host:
sudo chown -R ${runtime_uid}:${runtime_gid} <your-bind-mount-path>
Or set PUID/PGID to match the host directory owner:
PUID=${owner_uid}
PGID=${owner_gid}
To run intentionally as root, set:
SOLIDTIME_DROP_PRIVILEGES=never
For more help: https://docs.solidtime.io/self-hosting/guides/permissions
============================================================
EOF
}
write_test_as_user() {
local user="$1"
local script='
set -e
for dir in "$@"; do
test_file="${dir}/.solidtime-write-test"
touch "${test_file}"
rm -f "${test_file}"
done
'
if [ -n "${user}" ]; then
gosu "${user}" sh -c "${script}" sh "${WRITABLE_PATHS[@]}" 2>/dev/null
else
sh -c "${script}" sh "${WRITABLE_PATHS[@]}" 2>/dev/null
fi
}
# ----------------------------------------------------------------------------
# Root preamble: bootstrap, remap, chown, write-test, then drop and re-exec.
# ----------------------------------------------------------------------------
if [ "$(id -u)" = "0" ]; then
if ! bootstrap_storage_tree; then
echo "[entrypoint] ERROR: failed to create storage subdirectories at ${STORAGE_PATH}" >&2
exit 1
fi
if [ "${SHOULD_DROP}" = "1" ]; then
maybe_warn_ownership_mismatch
if [ "${TARGET_UID}" != "${DEFAULT_UID}" ] || [ "${TARGET_GID}" != "${DEFAULT_GID}" ]; then
echo "[entrypoint] Remapping ${APP_USER} to ${TARGET_UID}:${TARGET_GID}"
groupmod -o -g "${TARGET_GID}" "${APP_USER}"
usermod -o -u "${TARGET_UID}" "${APP_USER}"
fi
# Idempotent chown: only fix entries whose owner or group is wrong.
# On large storage volumes (lots of user uploads) this is dramatically
# faster than a blanket `chown -R` every restart. Pattern borrowed from
# docker-library/postgres and linuxserver.io's baseimage.
find "${STORAGE_PATH}" "${CACHE_PATH}" \
\( ! -user "${TARGET_UID}" -o ! -group "${TARGET_GID}" \) \
-exec chown "${TARGET_UID}:${TARGET_GID}" {} + 2>/dev/null || true
if ! write_test_as_user "${APP_USER}"; then
print_write_test_failure
exit 1
fi
exec gosu "${APP_USER}" env SOLIDTIME_PRIVILEGES_DROPPED=1 "$0" "$@"
fi
if ! write_test_as_user ""; then
print_write_test_failure
exit 1
fi
else
if ! bootstrap_storage_tree; then
echo "[entrypoint] WARNING: could not create some storage subdirectories at ${STORAGE_PATH} (will continue if existing tree is writable)" >&2
fi
if ! write_test_as_user ""; then
print_write_test_failure
exit 1
fi
fi
# ----------------------------------------------------------------------------
# Application: runs as APP_USER (or whatever non-root UID was started).
# ----------------------------------------------------------------------------
unset SOLIDTIME_PRIVILEGES_DROPPED
container_mode=${CONTAINER_MODE:-"http"}
octane_server=${OCTANE_SERVER}
auto_db_migrate=${AUTO_DB_MIGRATE:-false}
@@ -8,14 +242,16 @@ auto_db_migrate=${AUTO_DB_MIGRATE:-false}
initialStuff() {
echo "Container mode: $container_mode"
if [ ${auto_db_migrate} = "true" ]; then
if [ "${auto_db_migrate}" = "true" ]; then
echo "Auto database migration enabled."
php artisan migrate --isolated --force
fi
php artisan storage:link; \
php artisan optimize:clear; \
php artisan optimize;
if [ ! -L "${APP_PATH}/public/storage" ]; then
php artisan storage:link
fi
php artisan optimize:clear
php artisan optimize
}
if [ "$1" != "" ]; then
@@ -23,11 +259,11 @@ if [ "$1" != "" ]; then
elif [ "${container_mode}" = "http" ]; then
initialStuff
echo "Octane Server: $octane_server"
if [ "${octane_server}" = "frankenphp" ]; then
if [ "${octane_server}" = "frankenphp" ]; then
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.frankenphp.conf
elif [ "${octane_server}" = "swoole" ]; then
elif [ "${octane_server}" = "swoole" ]; then
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.swoole.conf
elif [ "${octane_server}" = "roadrunner" ]; then
elif [ "${octane_server}" = "roadrunner" ]; then
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.roadrunner.conf
else
echo "Invalid Octane server supplied."

View File

@@ -1,6 +1,5 @@
[supervisord]
nodaemon = true
user = %(ENV_USER)s
logfile = /var/log/supervisor/supervisord.log
pidfile = /var/run/supervisord.pid

View File

@@ -1,7 +1,6 @@
[program:horizon]
process_name = %(program_name)s_%(process_num)s
command = php %(ENV_ROOT)s/artisan horizon
user = %(ENV_USER)s
autostart = true
autorestart = true
stdout_logfile = /dev/stdout

View File

@@ -1,7 +1,6 @@
[program:reverb]
process_name = %(program_name)s_%(process_num)s
command = php %(ENV_ROOT)s/artisan reverb:start
user = %(ENV_USER)s
autostart = true
autorestart = true
stdout_logfile = /dev/stdout

View File

@@ -1,7 +1,6 @@
[program:scheduler]
process_name = %(program_name)s_%(process_num)s
command = supercronic -overlapping /etc/supercronic/laravel
user = %(ENV_USER)s
autostart = true
autorestart = true
stdout_logfile = /dev/stdout
@@ -12,7 +11,6 @@ stderr_logfile_maxbytes = 0
[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 = true
autorestart = false
startsecs = 0

View File

@@ -1,7 +1,6 @@
[program:worker]
process_name = %(program_name)s_%(process_num)s
command = %(ENV_WORKER_COMMAND)s
user = %(ENV_USER)s
autostart = true
autorestart = true
stdout_logfile = /dev/stdout

View File

@@ -230,6 +230,37 @@ test('test that theme can be changed to dark and light', async ({ page }) => {
await expect(page.getByText('System default:')).toBeVisible();
});
// =============================================
// Group similar time entries
// =============================================
test('test that group similar time entries setting can be toggled', async ({ page }) => {
await goToProfilePage(page);
// Get the checkbox
const checkbox = page.getByLabel('Group similar time entries');
// Get initial value and verify it is checked (default is true)
const initialValue = await checkbox.isChecked();
await expect(checkbox).toBeChecked();
// Toggle the checkbox
await checkbox.click();
// Reload
await page.reload();
// Verify the value is toggled
const afterValue = await page.getByLabel('Group similar time entries').isChecked();
expect(afterValue).toBe(!initialValue);
// Verify localStorage persists the setting
const storedValue = await page.evaluate(() =>
localStorage.getItem('group-similar-time-entries')
);
expect(storedValue).toBe(String(!initialValue));
});
// =============================================
// Two Factor Authentication Tests
// =============================================

View File

@@ -32,7 +32,7 @@ test('test that detailed view shows time entries correctly', async ({ page, ctx
// Verify the time entry is shown with all details
await expect(page.getByText(projectName, { exact: true }).first()).toBeVisible();
await expect(page.locator('input[name="Duration"]').first()).toHaveValue('1h 00min');
await expect(page.locator('input[name="Duration"]').first()).toHaveValue('1:00:00');
await expect(page.getByText('Entry for ' + projectName, { exact: true }).first()).toBeVisible();
});
@@ -62,8 +62,8 @@ test('test that updating duration in detailed view works correctly', async ({ pa
),
]);
// Verify the new duration is displayed
await expect(durationInput).toHaveValue(updatedDuration);
// Verify the new duration is displayed (reporting views promote to HH:MM:SS format)
await expect(durationInput).toHaveValue('2:30:00');
});
// ──────────────────────────────────────────────────

View File

@@ -333,7 +333,7 @@ test('test that task filtering works in reporting', async ({ page, ctx }) => {
await page.keyboard.press('Escape');
// Verify the report only shows 1h (task1's duration)
await expect(page.getByTestId('reporting_view').getByText('1h 00min').first()).toBeVisible();
await expect(page.getByTestId('reporting_view').getByText('1:00:00').first()).toBeVisible();
});
test('test that task multiselect search filters the option list', async ({ page, ctx }) => {
@@ -474,7 +474,7 @@ test('test that tag filtering works in reporting', async ({ page, ctx }) => {
await page.keyboard.press('Escape');
// Verify only time entries with tag1 are shown
await expect(page.getByTestId('reporting_view').getByText('1h 00min').first()).toBeVisible();
await expect(page.getByTestId('reporting_view').getByText('1:00:00').first()).toBeVisible();
});
test('test that tag dropdown search filters the option list', async ({ page, ctx }) => {
@@ -594,7 +594,7 @@ test('test that billable status filtering works in reporting', async ({ page, ct
waitForReportingUpdate(page),
]);
await expect(page.getByTestId('reporting_view').getByText('1h 00min').first()).toBeVisible();
await expect(page.getByTestId('reporting_view').getByText('1:00:00').first()).toBeVisible();
});
test('test that billable filter can switch between all three states', async ({ page }) => {
@@ -885,7 +885,7 @@ test.describe('Employee Reporting Restrictions', () => {
// Employee's data should be visible (1h)
await expect(
employee.page.getByTestId('reporting_view').getByText('1h 00min').first()
employee.page.getByTestId('reporting_view').getByText('1:00:00').first()
).toBeVisible();
});

View File

@@ -292,8 +292,8 @@ test('test that shared report respects task filter', async ({ page, ctx }) => {
await page.goto(shareableLink);
await expect(page.getByText('Reporting')).toBeVisible();
await expect(page.getByText('Total')).toBeVisible();
await expect(page.getByText('1h 00min').first()).toBeVisible();
await expect(page.getByText('3h 00min')).not.toBeVisible();
await expect(page.getByText('1:00:00').first()).toBeVisible();
await expect(page.getByText('3:00:00')).not.toBeVisible();
});
test('test that shared report respects client filter', async ({ page, ctx }) => {
@@ -369,8 +369,8 @@ test('test that shared report respects tag filter', async ({ page, ctx }) => {
await page.goto(shareableLink);
await expect(page.getByText('Reporting')).toBeVisible();
await expect(page.getByText('Total')).toBeVisible();
await expect(page.getByText('1h 00min').first()).toBeVisible();
await expect(page.getByText('3h 00min')).not.toBeVisible();
await expect(page.getByText('1:00:00').first()).toBeVisible();
await expect(page.getByText('3:00:00')).not.toBeVisible();
});
test('test that shared report respects member filter', async ({ page, ctx }) => {
@@ -425,7 +425,7 @@ test('test that shared report with billable filter only shows billable entries',
]);
// Verify only 1h shows before saving
await expect(page.getByTestId('reporting_view').getByText('1h 00min').first()).toBeVisible();
await expect(page.getByTestId('reporting_view').getByText('1:00:00').first()).toBeVisible();
const { shareableLink } = await saveAsSharedReport(page, reportName);
@@ -435,8 +435,8 @@ test('test that shared report with billable filter only shows billable entries',
await expect(page.getByText('Total')).toBeVisible();
// Shared report should only show the 1h billable entry, not the 2h non-billable
await expect(page.getByText('1h 00min').first()).toBeVisible();
await expect(page.getByText('3h 00min')).not.toBeVisible();
await expect(page.getByText('1:00:00').first()).toBeVisible();
await expect(page.getByText('3:00:00')).not.toBeVisible();
});
// ──────────────────────────────────────────────────

View File

@@ -39,6 +39,10 @@ function getMonthFromTimestamp(timestamp: string): number {
return new Date(timestamp).getUTCMonth() + 1;
}
async function goToProfilePage(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
}
async function goToTimeOverview(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
}
@@ -67,6 +71,14 @@ async function createEmptyTimeEntry(page: Page) {
]);
}
async function setTimeEntriesGrouping(page: Page, enabled: boolean) {
await goToProfilePage(page);
const checkbox = page.getByLabel('Group similar time entries');
const isChecked = await checkbox.isChecked();
if (isChecked !== enabled) await checkbox.click();
await goToTimeOverview(page);
}
test('test that starting and stopping an empty time entry shows a new time entry in the overview', async ({
page,
}) => {
@@ -333,6 +345,30 @@ test.skip('test that load more works when the end of page is reached', async ({
await expect(page.locator('body')).toHaveText(/All time entries are loaded!/);
});
test('test that Group similar time entries option is affected', async ({ page }) => {
// Enable grouping
await setTimeEntriesGrouping(page, true);
// Create 2 similar time entries
await createEmptyTimeEntry(page);
await page.waitForSelector('[data-testid="time_entry_row"]', { timeout: 1000 });
await createEmptyTimeEntry(page);
// Verify similar time entries are grouped
await expect(page.getByTestId('grouped_items_count_button').first()).toBeVisible({
timeout: 1000,
});
// Disable grouping
await setTimeEntriesGrouping(page, false);
// Verify similar time entries are not grouped
await expect(page.locator('[data-testid="time_entry_row"]')).toHaveCount(2, { timeout: 1000 });
await expect(page.locator('[data-testid="grouped_items_count_button"]')).toHaveCount(0, {
timeout: 1000,
});
});
// TODO: Test that updating the time entry start / end times works while it is running
// TODO: Test for project update

View File

@@ -2,7 +2,7 @@
import VChart, { THEME_KEY } from 'vue-echarts';
import { computed, provide, inject, shallowRef, type ComputedRef } from 'vue';
import LinearGradient from 'zrender/lib/graphic/LinearGradient';
import { formatDate, formatHumanReadableDuration, formatWeek } from '@/packages/ui/src/utils/time';
import { formatDate, formatReportingDuration, formatWeek } from '@/packages/ui/src/utils/time';
import { use } from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import { BarChart } from 'echarts/charts';
@@ -137,7 +137,7 @@ const option = computed(() => ({
type: 'bar',
tooltip: {
valueFormatter: (value: number) => {
return formatHumanReadableDuration(
return formatReportingDuration(
value,
organization?.value?.interval_format,
organization?.value?.number_format

View File

@@ -8,7 +8,7 @@ import {
import { SaveIcon } from 'lucide-vue-next';
import { getOrganizationCurrencyString } from '@/utils/money';
import {
formatHumanReadableDuration,
formatReportingDuration,
getDayJsInstance,
getLocalizedDayJs,
} from '@/packages/ui/src/utils/time';
@@ -426,7 +426,7 @@ const tableData = computed(() => {
class="justify-end flex items-center font-medium"
:class="!showBillableRate ? 'pr-6' : ''">
{{
formatHumanReadableDuration(
formatReportingDuration(
aggregatedTableTimeEntries.seconds,
organization?.interval_format,
organization?.number_format

View File

@@ -10,7 +10,7 @@ import {
TitleComponent,
TooltipComponent,
} from 'echarts/components';
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
import { formatReportingDuration } from '@/packages/ui/src/utils/time';
import { useCssVariable } from '@/packages/ui/src';
import type { Organization } from '@/packages/api/src';
@@ -67,7 +67,7 @@ const option = computed(() => ({
},
tooltip: {
valueFormatter: (value: number) => {
return formatHumanReadableDuration(
return formatReportingDuration(
value,
organization?.value?.interval_format,
organization?.value?.number_format

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
import { formatReportingDuration } from '@/packages/ui/src/utils/time';
import { formatCents } from '@/packages/ui/src/utils/money';
import GroupedItemsCountButton from '@/packages/ui/src/GroupedItemsCountButton.vue';
import { ref, inject, type ComputedRef } from 'vue';
@@ -44,7 +44,7 @@ const organization = inject<ComputedRef<Organization>>('organization');
</div>
<div class="justify-end flex items-center" :class="!showCost ? 'pr-6' : ''">
{{
formatHumanReadableDuration(
formatReportingDuration(
entry.seconds,
organization?.interval_format,
organization?.number_format

View File

@@ -16,7 +16,7 @@ import CardTitle from '@/packages/ui/src/CardTitle.vue';
import LinearGradient from 'zrender/lib/graphic/LinearGradient';
import ProjectsChartCard from '@/Components/Dashboard/ProjectsChartCard.vue';
import ThisWeekReportingTable from '@/Components/Dashboard/ThisWeekReportingTable.vue';
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
import { formatReportingDuration } from '@/packages/ui/src/utils/time';
import { formatCents } from '@/packages/ui/src/utils/money';
import { getWeekStart } from '@/packages/ui/src/utils/settings';
import { useCssVariable } from '@/packages/ui/src';
@@ -223,7 +223,7 @@ const option = computed(() => {
type: 'bar',
tooltip: {
valueFormatter: (value: number) => {
return formatHumanReadableDuration(
return formatReportingDuration(
value,
organization?.value?.interval_format,
organization?.value?.number_format
@@ -252,7 +252,7 @@ const option = computed(() => {
title="Spent Time"
:value="
totalWeeklyTime
? formatHumanReadableDuration(
? formatReportingDuration(
totalWeeklyTime,
organization?.interval_format,
organization?.number_format
@@ -263,7 +263,7 @@ const option = computed(() => {
title="Billable Time"
:value="
totalWeeklyBillableTime
? formatHumanReadableDuration(
? formatReportingDuration(
totalWeeklyBillableTime,
organization?.interval_format,
organization?.number_format

View File

@@ -2,7 +2,7 @@
import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue';
import ReportingGroupBySelect from '@/Components/Common/Reporting/ReportingGroupBySelect.vue';
import {
formatHumanReadableDuration,
formatReportingDuration,
getDayJsInstance,
getLocalizedDayJs,
} from '@/packages/ui/src/utils/time';
@@ -174,7 +174,7 @@ const showBillableRate = computed(() => {
class="justify-end flex items-center font-medium"
:class="!showBillableRate ? 'pr-6' : ''">
{{
formatHumanReadableDuration(
formatReportingDuration(
aggregatedTableTimeEntries.seconds,
organization?.interval_format,
organization?.number_format

View File

@@ -2,8 +2,10 @@
import FormSection from '@/Components/FormSection.vue';
import { Field, FieldLabel, FieldDescription } from '@/packages/ui/src/field';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/packages/ui/src';
import { Checkbox } from '@/packages/ui/src';
import { usePreferredColorScheme } from '@vueuse/core';
import { themeSetting } from '@/utils/theme';
import { groupSimilarTimeEntriesSetting } from '@/utils/timeEntryGrouping';
const preferredColor = usePreferredColorScheme();
</script>
@@ -15,6 +17,7 @@ const preferredColor = usePreferredColorScheme();
<template #description> Choose how you want solidtime to look on your device </template>
<template #form>
<!-- Theme -->
<Field class="col-span-6 sm:col-span-4">
<FieldLabel for="theme">Theme</FieldLabel>
<Select id="theme" v-model="themeSetting">
@@ -31,6 +34,14 @@ const preferredColor = usePreferredColorScheme();
System default: {{ preferredColor }}
</FieldDescription>
</Field>
<!-- Group similar time entries -->
<Field class="col-span-6 sm:col-span-4" orientation="horizontal">
<Checkbox
id="group_similar_time_entries"
v-model:checked="groupSimilarTimeEntriesSetting" />
<FieldLabel for="group_similar_time_entries">Group similar time entries</FieldLabel>
</Field>
</template>
</FormSection>
</template>

View File

@@ -390,6 +390,7 @@ async function downloadExport(format: ExportFormat) {
:organization-billable-rate="organization?.billable_rate ?? null"
:duplicate-time-entry="() => createTimeEntry(entry)"
:members="members"
is-report
show-date
show-member
:time-entry="entry"

View File

@@ -3,7 +3,7 @@ import MainContainer from '@/packages/ui/src/MainContainer.vue';
import PageTitle from '@/Components/Common/PageTitle.vue';
import { ChartBarIcon } from '@heroicons/vue/20/solid';
import ReportingChart from '@/Components/Common/Reporting/ReportingChart.vue';
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
import { formatReportingDuration } from '@/packages/ui/src/utils/time';
import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue';
import ReportingPieChart from '@/Components/Common/Reporting/ReportingPieChart.vue';
import { formatCents } from '@/packages/ui/src/utils/money';
@@ -231,7 +231,7 @@ onMounted(async () => {
</div>
<div class="justify-end flex items-center font-medium">
{{
formatHumanReadableDuration(
formatReportingDuration(
aggregatedTableTimeEntries.seconds,
reportIntervalFormat,
reportNumberFormat

View File

@@ -1,8 +1,8 @@
<script setup lang="ts">
import FormSection from '@/Components/FormSection.vue';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import { onMounted, ref } from 'vue';
import { Field, FieldLabel } from '@/packages/ui/src/field';
import { computed, onMounted, ref } from 'vue';
import { Field, FieldDescription, FieldLabel } from '@/packages/ui/src/field';
import type { UpdateOrganizationBody } from '@/packages/api/src';
import { useOrganizationStore } from '@/utils/useOrganization';
import { storeToRefs } from 'pinia';
@@ -52,6 +52,12 @@ onMounted(async () => {
}
});
const showsHhMmSsInReports = computed(
() =>
form.value.interval_format === 'hours-minutes' ||
form.value.interval_format === 'hours-minutes-colon-separated'
);
async function submit() {
mutation.mutate(form.value);
}
@@ -149,6 +155,12 @@ async function submit() {
>
</SelectContent>
</Select>
<FieldDescription v-if="showsHhMmSsInReports">
Reports and totals shown next to cost use HH:MM:SS for this format, so the
duration reconciles with the billable amount down to the second. Everywhere else
(time tracker, calendar, entry rows) seconds are omitted and durations stay in
your chosen format.
</FieldDescription>
</Field>
</template>

View File

@@ -16,6 +16,7 @@ import { useElementVisibility } from '@vueuse/core';
import { ClockIcon } from '@heroicons/vue/20/solid';
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
import { groupSimilarTimeEntriesSetting } from '@/utils/timeEntryGrouping';
import { useTasksQuery } from '@/utils/useTasksQuery';
import { useProjectsQuery } from '@/utils/useProjectsQuery';
import TimeEntryGroupedTable from '@/packages/ui/src/TimeEntry/TimeEntryGroupedTable.vue';
@@ -151,6 +152,7 @@ function deleteSelected() {
:tasks="tasks"
:currency="getOrganizationCurrencyString()"
:time-entries="timeEntries"
:group-similar-time-entries="groupSimilarTimeEntriesSetting"
:tags="tags"></TimeEntryGroupedTable>
<div v-if="isPending" class="flex justify-center items-center py-12">
<LoadingSpinner></LoadingSpinner>

View File

@@ -1,6 +1,6 @@
{
"name": "@solidtime/ui",
"version": "0.0.20",
"version": "0.0.21",
"description": "Package containing the solidtime ui components",
"main": "./dist/solidtime-ui-lib.umd.cjs",
"module": "./dist/solidtime-ui-lib.js",

View File

@@ -6,10 +6,15 @@ const props = withDefaults(
defineProps<{
expanded?: boolean;
size?: string;
/**
* Test ID used for Playwright/E2E tests.
*/
testId?: string;
}>(),
{
expanded: false,
size: 'w-7 h-7',
testId: 'grouped_items_count_button',
}
);
@@ -23,6 +28,7 @@ const expandedStatusClasses = computed(() => {
<template>
<button
:data-testid="props.testId"
:class="
twMerge(
'font-medium text-base rounded flex items-center transition justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-transparent',

View File

@@ -20,24 +20,30 @@ const selectedTimeEntries = defineModel<TimeEntry[]>('selected', {
default: [],
});
const props = defineProps<{
timeEntries: TimeEntry[];
projects: Project[];
tasks: Task[];
tags: Tag[];
clients: Client[];
createTag: (name: string) => Promise<Tag | undefined>;
updateTimeEntry: (entry: TimeEntry) => void;
updateTimeEntries: (ids: string[], changes: Partial<TimeEntry>) => void;
deleteTimeEntries: (entries: TimeEntry[]) => void;
createTimeEntry: (entry: Omit<CreateTimeEntryBody, 'member_id'>) => void;
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
currency: string;
organizationBillableRate: number | null;
enableEstimatedTime: boolean;
canCreateProject: boolean;
}>();
const props = withDefaults(
defineProps<{
timeEntries: TimeEntry[];
projects: Project[];
tasks: Task[];
tags: Tag[];
clients: Client[];
createTag: (name: string) => Promise<Tag | undefined>;
updateTimeEntry: (entry: TimeEntry) => void;
updateTimeEntries: (ids: string[], changes: Partial<TimeEntry>) => void;
deleteTimeEntries: (entries: TimeEntry[]) => void;
createTimeEntry: (entry: Omit<CreateTimeEntryBody, 'member_id'>) => void;
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
currency: string;
organizationBillableRate: number | null;
enableEstimatedTime: boolean;
canCreateProject: boolean;
groupSimilarTimeEntries?: boolean;
}>(),
{
groupSimilarTimeEntries: true,
}
);
const groupedTimeEntries = computed(() => {
const groupedEntriesByDay: Record<string, TimeEntry[]> = {};
@@ -58,6 +64,11 @@ const groupedTimeEntries = computed(() => {
const newDailyEntries: TimeEntriesGroupedByType[] = [];
for (const entry of dailyEntries) {
if (!props.groupSimilarTimeEntries) {
newDailyEntries.push({ ...entry, timeEntries: [entry] });
continue;
}
// check if same entry already exists
const oldEntriesIndex = newDailyEntries.findIndex(
(e) =>

View File

@@ -52,6 +52,7 @@ const props = defineProps<{
selected?: boolean;
canCreateProject: boolean;
enableEstimatedTime: boolean;
isReport?: boolean;
}>();
const emit = defineEmits<{ selected: []; unselected: [] }>();
@@ -172,6 +173,7 @@ async function handleDeleteTimeEntry() {
<TimeEntryRowDurationInput
:start="timeEntry.start"
:end="timeEntry.end"
:is-report="props.isReport"
@changed="updateStartEndTime"></TimeEntryRowDurationInput>
<TimeTrackerStartStop
:active="!!(timeEntry.start && !timeEntry.end)"
@@ -196,6 +198,7 @@ async function handleDeleteTimeEntry() {
<TimeEntryRowDurationInput
:start="timeEntry.start"
:end="timeEntry.end"
:is-report="props.isReport"
@changed="updateStartEndTime"></TimeEntryRowDurationInput>
</div>
<!-- Second row: project/task - tags - billable - start - more -->

View File

@@ -2,6 +2,7 @@
import {
calculateDifference,
formatHumanReadableDuration,
formatReportingDuration,
parseTimeInput,
} from '@/packages/ui/src/utils/time';
import { computed, ref, inject, type ComputedRef } from 'vue';
@@ -18,6 +19,7 @@ const organizationSettings = computed(() => ({
const props = defineProps<{
start: string;
end: string | null;
isReport?: boolean;
}>();
const emit = defineEmits<{
changed: [start: string, end: string | null];
@@ -51,7 +53,8 @@ const currentTime = computed({
if (temporaryCustomTimerEntry.value !== '') {
return temporaryCustomTimerEntry.value;
}
return formatHumanReadableDuration(
const formatter = props.isReport ? formatReportingDuration : formatHumanReadableDuration;
return formatter(
calculateDifference(props.start, props.end),
organizationSettings.value.intervalFormat,
organizationSettings.value.numberFormat

View File

@@ -118,6 +118,26 @@ export function formatHumanReadableDuration(
}
}
/**
* Format a duration for reporting views where cost and duration must reconcile.
*
* When the org's `hours-minutes` format is selected, seconds are normally dropped for
* readability (e.g. "14h 45min"). In reports this can make the total duration appear
* inconsistent with the billable cost (which is computed to the second). To keep the
* two columns reconcilable without inflating column widths with "14h 45min 06s",
* promote to the compact `HH:MM:SS` format in reporting contexts.
*/
export function formatReportingDuration(
duration: number,
intervalFormat?: string,
numberFormat?: string
): string {
const promoted =
intervalFormat === 'hours-minutes' || intervalFormat === 'hours-minutes-colon-separated';
const effectiveFormat = promoted ? 'hours-minutes-seconds-colon-separated' : intervalFormat;
return formatHumanReadableDuration(duration, effectiveFormat, numberFormat);
}
export function formatDuration(duration: number): string {
const dayJsDuration = dayjs.duration(duration, 's');
const hours = Math.floor(dayJsDuration.asHours());

View File

@@ -0,0 +1,6 @@
import { useStorage } from '@vueuse/core';
export const groupSimilarTimeEntriesSetting = useStorage<boolean>(
'group-similar-time-entries',
true
);

View File

@@ -150,7 +150,7 @@
<div style="padding: 8px 12px; border-radius: 8px;">
<div style="color: #71717a; font-weight: 600;">Duration</div>
<div
style="font-size: 24px; font-weight: 500; margin-top: 2px;">{{ $localization->formatInterval(CarbonInterval::seconds($aggregatedData['seconds'])) }} </div>
style="font-size: 24px; font-weight: 500; margin-top: 2px;">{{ $localization->formatIntervalForReporting(CarbonInterval::seconds($aggregatedData['seconds'])) }} </div>
</div>
@if($showBillableRate)
<div style="padding: 8px 12px; border-radius: 8px;">
@@ -199,7 +199,7 @@
</span>
</td>
<td style="text-align: left;">
{{ $localization->formatInterval(CarbonInterval::seconds($group1Entry['seconds'])) }}
{{ $localization->formatIntervalForReporting(CarbonInterval::seconds($group1Entry['seconds'])) }}
</td>
@if($showBillableRate)
<td style="text-align: right;">
@@ -214,7 +214,7 @@
Total
</td>
<td style="font-weight: 500;color: #18181b;">
{{ $localization->formatInterval(CarbonInterval::seconds($aggregatedData['seconds'])) }}
{{ $localization->formatIntervalForReporting(CarbonInterval::seconds($aggregatedData['seconds'])) }}
</td>
@if($showBillableRate)
<td style="text-align: right; font-weight: 500;color: #18181b;">
@@ -282,7 +282,7 @@
@endif
</td>
<td>
{{ $localization->formatInterval($duration) }}
{{ $localization->formatIntervalForReporting($duration) }}
</td>
<td>
{{ $localization->formatNumber($duration->totalHours) }}
@@ -403,7 +403,7 @@
type: "bar",
data: {!! json_encode(collect($dataHistoryChart['grouped_data'])->map(fn($value) => (object) [
'value' => $value['seconds'],
'name' => ((int) $value['seconds']) === 0 ? '' : $localization->formatInterval(CarbonInterval::seconds((int) $value['seconds']))
'name' => ((int) $value['seconds']) === 0 ? '' : $localization->formatIntervalForReporting(CarbonInterval::seconds((int) $value['seconds']))
])->toArray()) !!},
itemStyle: {
borderColor: "#7dd3fc",

View File

@@ -138,7 +138,7 @@
<div style="padding: 8px 12px; border-radius: 8px;">
<div style="color: #71717a; font-weight: 600;">Duration</div>
<div
style="font-size: 24px; font-weight: 500; margin-top: 2px;">{{ $localization->formatInterval(CarbonInterval::seconds($aggregatedData['seconds'])) }} </div>
style="font-size: 24px; font-weight: 500; margin-top: 2px;">{{ $localization->formatIntervalForReporting(CarbonInterval::seconds($aggregatedData['seconds'])) }} </div>
</div>
@if($showBillableRate)
<div style="padding: 8px 12px; border-radius: 8px;">
@@ -189,7 +189,7 @@
{{ $localization->formatTime($timeEntry->start->timezone($timezone)) }} - {{ $localization->formatTime($timeEntry->end->timezone($timezone)) }}
</td>
<td style="overflow-wrap: break-word; min-width: 75px;">
{{ $localization->formatInterval($timeEntry->getDuration()) }}
{{ $localization->formatIntervalForReporting($timeEntry->getDuration()) }}
</td>
<td style="overflow-wrap: break-word;">{{ $timeEntry->billable ? 'Yes' : 'No' }}</td>
<td style="overflow-wrap: break-word; min-width: 75px;">{{ count($timeEntry->tagsRelation) === 0 ? '-' : $timeEntry->tagsRelation->implode('name', ', ') }}</td>

View File

@@ -2490,6 +2490,47 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
$response->assertForbidden();
}
public function test_update_endpoint_fails_if_time_entry_belongs_to_different_organization_than_url_even_with_update_all_permission(): void
{
// Arrange
// Attacker: has `time-entries:update:all` in their own organization (orgA).
$data = $this->createUserWithPermission([
'time-entries:update:all',
'projects:view:all',
]);
$attackerProject = Project::factory()->forOrganization($data->organization)->create();
// Victim: entirely separate organization (orgB). Attacker has NO membership in orgB.
$victimOrgData = $this->createUserWithPermission([], true);
$victimTimeEntry = TimeEntry::factory()
->forOrganization($victimOrgData->organization)
->forMember($victimOrgData->ownerMember)
->create([
'description' => 'victim-original',
'project_id' => null,
'task_id' => null,
]);
Passport::actingAs($data->user);
// Act: PUT to /organizations/{orgA}/time-entries/{victim_uuid} — URL org is attacker's
// own org, but the route-bound time entry belongs to orgB.
$response = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $victimTimeEntry->getKey()]), [
'description' => 'attacker-overwrite',
'project_id' => $attackerProject->getKey(),
]);
// Assert: must be rejected. Before the fix this returned 200 and rewrote the
// victim row with attacker's project_id while keeping organization_id = orgB.
$response->assertForbidden();
$this->assertDatabaseHas(TimeEntry::class, [
'id' => $victimTimeEntry->getKey(),
'organization_id' => $victimOrgData->organization->getKey(),
'description' => 'victim-original',
'project_id' => null,
]);
}
public function test_update_endpoint_fails_if_user_has_no_permission_to_update_time_entries_for_other_users_in_organization(): void
{
// Arrange

View File

@@ -129,6 +129,58 @@ class LocalizationServiceTest extends TestCaseWithDatabase
$this->assertSame('30001:03:04', $formatted);
}
public function test_format_interval_for_reporting_with_type_decimal(): void
{
// Arrange
$interval = CarbonInterval::seconds(4 + (60 * 3) + (60 * 60 * 30001));
$this->localizationService->setIntervalFormat(IntervalFormat::Decimal);
// Act
$formatted = $this->localizationService->formatIntervalForReporting($interval);
// Assert
$this->assertSame('30.001,05 h', $formatted);
}
public function test_format_interval_for_reporting_with_type_hours_minutes(): void
{
// Arrange
$interval = CarbonInterval::seconds(4 + (60 * 3) + (60 * 60 * 30001));
$this->localizationService->setIntervalFormat(IntervalFormat::HoursMinutes);
// Act
$formatted = $this->localizationService->formatIntervalForReporting($interval);
// Assert
$this->assertSame('30001:03:04', $formatted);
}
public function test_format_interval_for_reporting_with_type_hours_minutes_colon_separated(): void
{
// Arrange
$interval = CarbonInterval::seconds(4 + (60 * 3) + (60 * 60 * 30001));
$this->localizationService->setIntervalFormat(IntervalFormat::HoursMinutesColonSeparated);
// Act
$formatted = $this->localizationService->formatIntervalForReporting($interval);
// Assert
$this->assertSame('30001:03:04', $formatted);
}
public function test_format_interval_for_reporting_with_type_hours_minutes_seconds_colon_separated(): void
{
// Arrange
$interval = CarbonInterval::seconds(4 + (60 * 3) + (60 * 60 * 30001));
$this->localizationService->setIntervalFormat(IntervalFormat::HoursMinutesSecondsColonSeparated);
// Act
$formatted = $this->localizationService->formatIntervalForReporting($interval);
// Assert
$this->assertSame('30001:03:04', $formatted);
}
public function test_format_currency_with_type_symbol_after_with_space_and_number_format_thousands_space_decimal_comma(): void
{
// Arrange