mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 08:12:17 +01:00
Compare commits
11 Commits
1cf33ddb3f
...
feature/do
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
595132ec2c | ||
|
|
0659cb3993 | ||
|
|
e8fc6fd77e | ||
|
|
a04185921d | ||
|
|
b73aa543fd | ||
|
|
2d6f9e514f | ||
|
|
f8e668790b | ||
|
|
77a5e979c6 | ||
|
|
353a579850 | ||
|
|
bd44a2b376 | ||
|
|
277dbaf6eb |
258
.github/workflows/image-smoke-test.yml
vendored
Normal file
258
.github/workflows/image-smoke-test.yml
vendored
Normal 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"
|
||||
@@ -1,4 +1,4 @@
|
||||
# solidtime - The modern Open-Source Time Tracker
|
||||
# solidtime - The modern Open-Source TimeTracker
|
||||
|
||||
[](https://github.com/solidtime-io/solidtime/blob/main/LICENSE.md)
|
||||
[](https://codecov.io/gh/solidtime-io/solidtime)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
[supervisord]
|
||||
nodaemon = true
|
||||
user = %(ENV_USER)s
|
||||
logfile = /var/log/supervisor/supervisord.log
|
||||
pidfile = /var/run/supervisord.pid
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
// =============================================
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
|
||||
6
resources/js/utils/timeEntryGrouping.ts
Normal file
6
resources/js/utils/timeEntryGrouping.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { useStorage } from '@vueuse/core';
|
||||
|
||||
export const groupSimilarTimeEntriesSetting = useStorage<boolean>(
|
||||
'group-similar-time-entries',
|
||||
true
|
||||
);
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user