mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-11 01:32:16 +01:00
Compare commits
27 Commits
v0.12.0
...
feature/do
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
595132ec2c | ||
|
|
0659cb3993 | ||
|
|
e8fc6fd77e | ||
|
|
a04185921d | ||
|
|
b73aa543fd | ||
|
|
2d6f9e514f | ||
|
|
f8e668790b | ||
|
|
77a5e979c6 | ||
|
|
353a579850 | ||
|
|
bd44a2b376 | ||
|
|
277dbaf6eb | ||
|
|
1cf33ddb3f | ||
|
|
84cd0d572d | ||
|
|
f37b86f377 | ||
|
|
1e7364fc4b | ||
|
|
8cbc9838c9 | ||
|
|
71c8992e31 | ||
|
|
53d91b65d6 | ||
|
|
0c88a10eb5 | ||
|
|
dd7b23958a | ||
|
|
1eb066f5aa | ||
|
|
b1287c6a0a | ||
|
|
815abb5980 | ||
|
|
e2f859be27 | ||
|
|
3d26fcaefe | ||
|
|
1e73a90f9d | ||
|
|
0f8f906e5c |
2
.env.ci
2
.env.ci
@@ -60,7 +60,7 @@ AUDITING_ENABLED=true
|
|||||||
TELESCOPE_ENABLED=false
|
TELESCOPE_ENABLED=false
|
||||||
|
|
||||||
# Services
|
# Services
|
||||||
GOTENBERG_URL=http://0.0.0.0:3000
|
GOTENBERG_URL=http://localhost:3000
|
||||||
|
|
||||||
# Octane
|
# Octane
|
||||||
OCTANE_SERVER=frankenphp
|
OCTANE_SERVER=frankenphp
|
||||||
|
|||||||
@@ -77,6 +77,9 @@ TELESCOPE_ENABLED=false
|
|||||||
# Services
|
# Services
|
||||||
GOTENBERG_URL=http://gotenberg:3000
|
GOTENBERG_URL=http://gotenberg:3000
|
||||||
|
|
||||||
|
# Octane
|
||||||
|
OCTANE_SERVER=frankenphp
|
||||||
|
|
||||||
# Local setup
|
# Local setup
|
||||||
NGINX_HOST_NAME=solidtime.test
|
NGINX_HOST_NAME=solidtime.test
|
||||||
NETWORK_NAME=reverse-proxy-docker-traefik_routing
|
NETWORK_NAME=reverse-proxy-docker-traefik_routing
|
||||||
|
|||||||
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://github.com/solidtime-io/solidtime/blob/main/LICENSE.md)
|
||||||
[](https://codecov.io/gh/solidtime-io/solidtime)
|
[](https://codecov.io/gh/solidtime-io/solidtime)
|
||||||
|
|||||||
@@ -629,9 +629,9 @@ class TimeEntryController extends Controller
|
|||||||
/** @var Member|null $member */
|
/** @var Member|null $member */
|
||||||
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
$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())) {
|
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 {
|
} 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) {
|
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
|
public function formatCurrency(Money $money): string
|
||||||
{
|
{
|
||||||
$currencyService = app(CurrencyService::class);
|
$currencyService = app(CurrencyService::class);
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ RUN apt-get update; \
|
|||||||
wget \
|
wget \
|
||||||
vim \
|
vim \
|
||||||
git \
|
git \
|
||||||
|
gosu \
|
||||||
ncdu \
|
ncdu \
|
||||||
procps \
|
procps \
|
||||||
unzip \
|
unzip \
|
||||||
@@ -193,9 +194,20 @@ COPY --link --chown=${WWWUSER}:${WWWUSER} . .
|
|||||||
#COPY --link --chown=${WWWUSER}:${WWWUSER} --from=build ${ROOT}/public public
|
#COPY --link --chown=${WWWUSER}:${WWWUSER} --from=build ${ROOT}/public public
|
||||||
|
|
||||||
RUN mkdir -p \
|
RUN mkdir -p \
|
||||||
storage/framework/{sessions,views,cache,testing} \
|
storage/framework/{sessions,views,cache/data,testing} \
|
||||||
storage/logs \
|
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 \
|
#RUN composer install \
|
||||||
# --classmap-authoritative \
|
# --classmap-authoritative \
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
[program:octane]
|
[program:octane]
|
||||||
process_name = %(program_name)s_%(process_num)s
|
process_name = %(program_name)s_%(process_num)s
|
||||||
command = php %(ENV_ROOT)s/artisan octane:frankenphp --host=0.0.0.0 --port=8000 --admin-port=2019 --caddyfile=%(ENV_ROOT)s/docker/prod/deployment/octane/FrankenPHP/Caddyfile
|
command = php %(ENV_ROOT)s/artisan octane:frankenphp --host=0.0.0.0 --port=8000 --admin-port=2019 --caddyfile=%(ENV_ROOT)s/docker/prod/deployment/octane/FrankenPHP/Caddyfile
|
||||||
user = %(ENV_USER)s
|
|
||||||
priority = 1
|
priority = 1
|
||||||
autostart = true
|
autostart = true
|
||||||
autorestart = true
|
autorestart = true
|
||||||
@@ -14,7 +13,6 @@ stderr_logfile_maxbytes = 0
|
|||||||
[program:horizon]
|
[program:horizon]
|
||||||
process_name = %(program_name)s_%(process_num)s
|
process_name = %(program_name)s_%(process_num)s
|
||||||
command = php %(ENV_ROOT)s/artisan horizon
|
command = php %(ENV_ROOT)s/artisan horizon
|
||||||
user = %(ENV_USER)s
|
|
||||||
priority = 3
|
priority = 3
|
||||||
autostart = %(ENV_WITH_HORIZON)s
|
autostart = %(ENV_WITH_HORIZON)s
|
||||||
autorestart = true
|
autorestart = true
|
||||||
@@ -27,7 +25,6 @@ stopwaitsecs = 3600
|
|||||||
[program:scheduler]
|
[program:scheduler]
|
||||||
process_name = %(program_name)s_%(process_num)s
|
process_name = %(program_name)s_%(process_num)s
|
||||||
command = supercronic -overlapping /etc/supercronic/laravel
|
command = supercronic -overlapping /etc/supercronic/laravel
|
||||||
user = %(ENV_USER)s
|
|
||||||
autostart = %(ENV_WITH_SCHEDULER)s
|
autostart = %(ENV_WITH_SCHEDULER)s
|
||||||
autorestart = true
|
autorestart = true
|
||||||
stdout_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
|
stdout_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
|
||||||
@@ -38,7 +35,6 @@ stderr_logfile_maxbytes = 200MB
|
|||||||
[program:clear-scheduler-cache]
|
[program:clear-scheduler-cache]
|
||||||
process_name = %(program_name)s_%(process_num)s
|
process_name = %(program_name)s_%(process_num)s
|
||||||
command = php %(ENV_ROOT)s/artisan schedule:clear-cache
|
command = php %(ENV_ROOT)s/artisan schedule:clear-cache
|
||||||
user = %(ENV_USER)s
|
|
||||||
autostart = %(ENV_WITH_SCHEDULER)s
|
autostart = %(ENV_WITH_SCHEDULER)s
|
||||||
autorestart = false
|
autorestart = false
|
||||||
startsecs = 0
|
startsecs = 0
|
||||||
@@ -51,7 +47,6 @@ stderr_logfile_maxbytes = 200MB
|
|||||||
[program:reverb]
|
[program:reverb]
|
||||||
process_name = %(program_name)s_%(process_num)s
|
process_name = %(program_name)s_%(process_num)s
|
||||||
command = php %(ENV_ROOT)s/artisan reverb:start
|
command = php %(ENV_ROOT)s/artisan reverb:start
|
||||||
user = %(ENV_USER)s
|
|
||||||
priority = 2
|
priority = 2
|
||||||
autostart = %(ENV_WITH_REVERB)s
|
autostart = %(ENV_WITH_REVERB)s
|
||||||
autorestart = true
|
autorestart = true
|
||||||
|
|||||||
@@ -1,6 +1,240 @@
|
|||||||
#!/usr/bin/env sh
|
#!/bin/bash
|
||||||
set -e
|
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"}
|
container_mode=${CONTAINER_MODE:-"http"}
|
||||||
octane_server=${OCTANE_SERVER}
|
octane_server=${OCTANE_SERVER}
|
||||||
auto_db_migrate=${AUTO_DB_MIGRATE:-false}
|
auto_db_migrate=${AUTO_DB_MIGRATE:-false}
|
||||||
@@ -8,14 +242,16 @@ auto_db_migrate=${AUTO_DB_MIGRATE:-false}
|
|||||||
initialStuff() {
|
initialStuff() {
|
||||||
echo "Container mode: $container_mode"
|
echo "Container mode: $container_mode"
|
||||||
|
|
||||||
if [ ${auto_db_migrate} = "true" ]; then
|
if [ "${auto_db_migrate}" = "true" ]; then
|
||||||
echo "Auto database migration enabled."
|
echo "Auto database migration enabled."
|
||||||
php artisan migrate --isolated --force
|
php artisan migrate --isolated --force
|
||||||
fi
|
fi
|
||||||
|
|
||||||
php artisan storage:link; \
|
if [ ! -L "${APP_PATH}/public/storage" ]; then
|
||||||
php artisan optimize:clear; \
|
php artisan storage:link
|
||||||
php artisan optimize;
|
fi
|
||||||
|
php artisan optimize:clear
|
||||||
|
php artisan optimize
|
||||||
}
|
}
|
||||||
|
|
||||||
if [ "$1" != "" ]; then
|
if [ "$1" != "" ]; then
|
||||||
@@ -23,11 +259,11 @@ if [ "$1" != "" ]; then
|
|||||||
elif [ "${container_mode}" = "http" ]; then
|
elif [ "${container_mode}" = "http" ]; then
|
||||||
initialStuff
|
initialStuff
|
||||||
echo "Octane Server: $octane_server"
|
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
|
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
|
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
|
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.roadrunner.conf
|
||||||
else
|
else
|
||||||
echo "Invalid Octane server supplied."
|
echo "Invalid Octane server supplied."
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
[supervisord]
|
[supervisord]
|
||||||
nodaemon = true
|
nodaemon = true
|
||||||
user = %(ENV_USER)s
|
|
||||||
logfile = /var/log/supervisor/supervisord.log
|
logfile = /var/log/supervisor/supervisord.log
|
||||||
pidfile = /var/run/supervisord.pid
|
pidfile = /var/run/supervisord.pid
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
[program:horizon]
|
[program:horizon]
|
||||||
process_name = %(program_name)s_%(process_num)s
|
process_name = %(program_name)s_%(process_num)s
|
||||||
command = php %(ENV_ROOT)s/artisan horizon
|
command = php %(ENV_ROOT)s/artisan horizon
|
||||||
user = %(ENV_USER)s
|
|
||||||
autostart = true
|
autostart = true
|
||||||
autorestart = true
|
autorestart = true
|
||||||
stdout_logfile = /dev/stdout
|
stdout_logfile = /dev/stdout
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
[program:reverb]
|
[program:reverb]
|
||||||
process_name = %(program_name)s_%(process_num)s
|
process_name = %(program_name)s_%(process_num)s
|
||||||
command = php %(ENV_ROOT)s/artisan reverb:start
|
command = php %(ENV_ROOT)s/artisan reverb:start
|
||||||
user = %(ENV_USER)s
|
|
||||||
autostart = true
|
autostart = true
|
||||||
autorestart = true
|
autorestart = true
|
||||||
stdout_logfile = /dev/stdout
|
stdout_logfile = /dev/stdout
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
[program:scheduler]
|
[program:scheduler]
|
||||||
process_name = %(program_name)s_%(process_num)s
|
process_name = %(program_name)s_%(process_num)s
|
||||||
command = supercronic -overlapping /etc/supercronic/laravel
|
command = supercronic -overlapping /etc/supercronic/laravel
|
||||||
user = %(ENV_USER)s
|
|
||||||
autostart = true
|
autostart = true
|
||||||
autorestart = true
|
autorestart = true
|
||||||
stdout_logfile = /dev/stdout
|
stdout_logfile = /dev/stdout
|
||||||
@@ -12,7 +11,6 @@ stderr_logfile_maxbytes = 0
|
|||||||
[program:clear-scheduler-cache]
|
[program:clear-scheduler-cache]
|
||||||
process_name = %(program_name)s_%(process_num)s
|
process_name = %(program_name)s_%(process_num)s
|
||||||
command = php %(ENV_ROOT)s/artisan schedule:clear-cache
|
command = php %(ENV_ROOT)s/artisan schedule:clear-cache
|
||||||
user = %(ENV_USER)s
|
|
||||||
autostart = true
|
autostart = true
|
||||||
autorestart = false
|
autorestart = false
|
||||||
startsecs = 0
|
startsecs = 0
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
[program:worker]
|
[program:worker]
|
||||||
process_name = %(program_name)s_%(process_num)s
|
process_name = %(program_name)s_%(process_num)s
|
||||||
command = %(ENV_WORKER_COMMAND)s
|
command = %(ENV_WORKER_COMMAND)s
|
||||||
user = %(ENV_USER)s
|
|
||||||
autostart = true
|
autostart = true
|
||||||
autorestart = true
|
autorestart = true
|
||||||
stdout_logfile = /dev/stdout
|
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();
|
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
|
// 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
|
// Verify the time entry is shown with all details
|
||||||
await expect(page.getByText(projectName, { exact: true }).first()).toBeVisible();
|
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();
|
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
|
// Verify the new duration is displayed (reporting views promote to HH:MM:SS format)
|
||||||
await expect(durationInput).toHaveValue(updatedDuration);
|
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');
|
await page.keyboard.press('Escape');
|
||||||
|
|
||||||
// Verify the report only shows 1h (task1's duration)
|
// 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 }) => {
|
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');
|
await page.keyboard.press('Escape');
|
||||||
|
|
||||||
// Verify only time entries with tag1 are shown
|
// 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 }) => {
|
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),
|
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 }) => {
|
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)
|
// Employee's data should be visible (1h)
|
||||||
await expect(
|
await expect(
|
||||||
employee.page.getByTestId('reporting_view').getByText('1h 00min').first()
|
employee.page.getByTestId('reporting_view').getByText('1:00:00').first()
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -292,8 +292,8 @@ test('test that shared report respects task filter', async ({ page, ctx }) => {
|
|||||||
await page.goto(shareableLink);
|
await page.goto(shareableLink);
|
||||||
await expect(page.getByText('Reporting')).toBeVisible();
|
await expect(page.getByText('Reporting')).toBeVisible();
|
||||||
await expect(page.getByText('Total')).toBeVisible();
|
await expect(page.getByText('Total')).toBeVisible();
|
||||||
await expect(page.getByText('1h 00min').first()).toBeVisible();
|
await expect(page.getByText('1:00:00').first()).toBeVisible();
|
||||||
await expect(page.getByText('3h 00min')).not.toBeVisible();
|
await expect(page.getByText('3:00:00')).not.toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('test that shared report respects client filter', async ({ page, ctx }) => {
|
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 page.goto(shareableLink);
|
||||||
await expect(page.getByText('Reporting')).toBeVisible();
|
await expect(page.getByText('Reporting')).toBeVisible();
|
||||||
await expect(page.getByText('Total')).toBeVisible();
|
await expect(page.getByText('Total')).toBeVisible();
|
||||||
await expect(page.getByText('1h 00min').first()).toBeVisible();
|
await expect(page.getByText('1:00:00').first()).toBeVisible();
|
||||||
await expect(page.getByText('3h 00min')).not.toBeVisible();
|
await expect(page.getByText('3:00:00')).not.toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('test that shared report respects member filter', async ({ page, ctx }) => {
|
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
|
// 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);
|
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();
|
await expect(page.getByText('Total')).toBeVisible();
|
||||||
|
|
||||||
// Shared report should only show the 1h billable entry, not the 2h non-billable
|
// 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('1:00:00').first()).toBeVisible();
|
||||||
await expect(page.getByText('3h 00min')).not.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;
|
return new Date(timestamp).getUTCMonth() + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function goToProfilePage(page: Page) {
|
||||||
|
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
|
||||||
|
}
|
||||||
|
|
||||||
async function goToTimeOverview(page: Page) {
|
async function goToTimeOverview(page: Page) {
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
|
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 ({
|
test('test that starting and stopping an empty time entry shows a new time entry in the overview', async ({
|
||||||
page,
|
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!/);
|
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 that updating the time entry start / end times works while it is running
|
||||||
|
|
||||||
// TODO: Test for project update
|
// TODO: Test for project update
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
} from './utils/currentTimeEntry';
|
} from './utils/currentTimeEntry';
|
||||||
import type { Page } from '@playwright/test';
|
import type { Page } from '@playwright/test';
|
||||||
import { newTagResponse } from './utils/tags';
|
import { newTagResponse } from './utils/tags';
|
||||||
import { updateOrganizationCurrencyViaWeb } from './utils/api';
|
import { createProjectViaApi, updateOrganizationCurrencyViaWeb } from './utils/api';
|
||||||
|
|
||||||
// Date picker button name patterns for different date formats
|
// Date picker button name patterns for different date formats
|
||||||
const DATE_DISPLAY_PATTERN = /^\d{4}-\d{2}-\d{2}$|^\d{2}\/\d{2}\/\d{4}$|^\d{2}\.\d{2}\.\d{4}$/;
|
const DATE_DISPLAY_PATTERN = /^\d{4}-\d{2}-\d{2}$|^\d{2}\/\d{2}\/\d{4}$|^\d{2}\.\d{2}\.\d{4}$/;
|
||||||
@@ -368,6 +368,45 @@ test('test that timer started on dashboard is visible on time page', async ({ pa
|
|||||||
await assertThatTimerIsStopped(page);
|
await assertThatTimerIsStopped(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('test that creating a new project from the time tracker dropdown prefills the search text', async ({
|
||||||
|
page,
|
||||||
|
ctx,
|
||||||
|
}) => {
|
||||||
|
const existingProjectName = 'Existing Project ' + Math.floor(Math.random() * 10000);
|
||||||
|
const searchText = 'PrefillProject ' + Math.floor(Math.random() * 10000);
|
||||||
|
|
||||||
|
// Create a project so the dropdown renders (not the "Add new project" button)
|
||||||
|
await createProjectViaApi(ctx, { name: existingProjectName });
|
||||||
|
await goToDashboard(page);
|
||||||
|
|
||||||
|
// Open the project dropdown
|
||||||
|
await page.getByRole('button', { name: 'No Project' }).click();
|
||||||
|
|
||||||
|
// Type a search term that won't match any existing project
|
||||||
|
await page.getByTestId('client_dropdown_search').fill(searchText);
|
||||||
|
|
||||||
|
// Click "Create new Project"
|
||||||
|
await page.getByText('Create new Project').click();
|
||||||
|
|
||||||
|
// Verify the project name input is pre-filled with the search text
|
||||||
|
await expect(page.getByLabel('Project name')).toHaveValue(searchText);
|
||||||
|
|
||||||
|
// Complete project creation to verify full flow works
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
async (response) =>
|
||||||
|
response.url().includes('/projects') &&
|
||||||
|
response.request().method() === 'POST' &&
|
||||||
|
response.status() === 201 &&
|
||||||
|
(await response.json()).data.name === searchText
|
||||||
|
),
|
||||||
|
page.getByRole('button', { name: 'Create Project' }).click(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// The project dropdown should now show the newly created project
|
||||||
|
await expect(page.getByRole('button', { name: searchText })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
test('test that adding a project and tag before starting timer works', async ({ page }) => {
|
test('test that adding a project and tag before starting timer works', async ({ page }) => {
|
||||||
const newTagName = 'TimerTag ' + Math.floor(Math.random() * 10000);
|
const newTagName = 'TimerTag ' + Math.floor(Math.random() * 10000);
|
||||||
await goToDashboard(page);
|
await goToDashboard(page);
|
||||||
|
|||||||
8
package-lock.json
generated
8
package-lock.json
generated
@@ -4,6 +4,7 @@
|
|||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
|
"name": "solidtime",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"resources/js/packages/ui",
|
"resources/js/packages/ui",
|
||||||
"resources/js/packages/api"
|
"resources/js/packages/api"
|
||||||
@@ -7412,10 +7413,13 @@
|
|||||||
},
|
},
|
||||||
"resources/js/packages/ui": {
|
"resources/js/packages/ui": {
|
||||||
"name": "@solidtime/ui",
|
"name": "@solidtime/ui",
|
||||||
"version": "0.0.16",
|
"version": "0.0.17",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vite-plugin-dts": "^4.0.3"
|
"@types/chroma-js": "^3.1.0",
|
||||||
|
"@zodios/core": "^10.9.6",
|
||||||
|
"vite-plugin-dts": "^4.0.3",
|
||||||
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@floating-ui/vue": "^1.1.4",
|
"@floating-ui/vue": "^1.1.4",
|
||||||
|
|||||||
@@ -57,11 +57,11 @@ const showEditModal = ref(false);
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="whitespace-nowrap flex items-center px-3 py-4 text-sm font-medium text-text-primary">
|
class="whitespace-nowrap flex items-center px-3 py-4 text-sm text-text-primary">
|
||||||
<span class="text-text-secondary"> {{ projectCount }} Projects </span>
|
<span> {{ projectCount }} Projects </span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1.5 items-center font-medium">
|
class="whitespace-nowrap px-3 py-4 text-sm text-text-primary flex space-x-1.5 items-center">
|
||||||
<template v-if="client.is_archived">
|
<template v-if="client.is_archived">
|
||||||
<ArchiveBoxIcon class="w-4 text-icon-default"></ArchiveBoxIcon>
|
<ArchiveBoxIcon class="w-4 text-icon-default"></ArchiveBoxIcon>
|
||||||
<span>Archived</span>
|
<span>Archived</span>
|
||||||
|
|||||||
@@ -83,27 +83,28 @@ const userHasValidMailAddress = computed(() => {
|
|||||||
{{ member.name }}
|
{{ member.name }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
|
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-primary">
|
||||||
{{ member.email }}
|
{{ member.email }}
|
||||||
</div>
|
</div>
|
||||||
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
|
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-primary">
|
||||||
{{ capitalizeFirstLetter(member.role) }}
|
{{ capitalizeFirstLetter(member.role) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
|
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-primary">
|
||||||
{{
|
<span v-if="member.billable_rate">
|
||||||
member.billable_rate
|
{{
|
||||||
? formatCents(
|
formatCents(
|
||||||
member.billable_rate,
|
member.billable_rate,
|
||||||
organization?.currency,
|
organization?.currency,
|
||||||
organization?.currency_format,
|
organization?.currency_format,
|
||||||
organization?.currency_symbol,
|
organization?.currency_symbol,
|
||||||
organization?.number_format
|
organization?.number_format
|
||||||
)
|
)
|
||||||
: '--'
|
}}
|
||||||
}}
|
</span>
|
||||||
|
<span v-else class="text-text-tertiary"> -- </span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1.5 items-center font-medium">
|
class="whitespace-nowrap px-3 py-4 text-sm text-text-primary flex space-x-1.5 items-center">
|
||||||
<template v-if="member.is_placeholder === false">
|
<template v-if="member.is_placeholder === false">
|
||||||
<CheckCircleIcon class="w-4 text-icon-default"></CheckCircleIcon>
|
<CheckCircleIcon class="w-4 text-icon-default"></CheckCircleIcon>
|
||||||
<span>Active</span>
|
<span>Active</span>
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ const billableRateInfo = computed(() => {
|
|||||||
return 'Default Rate';
|
return 'Default Rate';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return '--';
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
const showEditProjectModal = ref(false);
|
const showEditProjectModal = ref(false);
|
||||||
@@ -98,13 +98,13 @@ const showEditProjectModal = ref(false);
|
|||||||
</span>
|
</span>
|
||||||
<span class="text-text-secondary"> {{ projectTasksCount }} Tasks </span>
|
<span class="text-text-secondary"> {{ projectTasksCount }} Tasks </span>
|
||||||
</div>
|
</div>
|
||||||
<div class="whitespace-nowrap min-w-0 px-3 py-4 text-sm text-text-secondary">
|
<div class="whitespace-nowrap min-w-0 px-3 py-4 text-sm text-text-primary">
|
||||||
<div v-if="project.client_id" class="overflow-ellipsis overflow-hidden">
|
<div v-if="project.client_id" class="overflow-ellipsis overflow-hidden">
|
||||||
{{ client?.name }}
|
{{ client?.name }}
|
||||||
</div>
|
</div>
|
||||||
<div v-else>No client</div>
|
<div v-else class="text-text-tertiary">No client</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
|
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-primary">
|
||||||
<div v-if="project.spent_time">
|
<div v-if="project.spent_time">
|
||||||
{{
|
{{
|
||||||
formatHumanReadableDuration(
|
formatHumanReadableDuration(
|
||||||
@@ -114,23 +114,24 @@ const showEditProjectModal = ref(false);
|
|||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
<div v-else>--</div>
|
<div v-else class="text-text-tertiary">--</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="whitespace-nowrap px-3 flex items-center text-sm text-text-secondary">
|
<div class="whitespace-nowrap px-3 flex items-center text-sm text-text-primary">
|
||||||
<UpgradeBadge v-if="!isAllowedToPerformPremiumAction()"></UpgradeBadge>
|
<UpgradeBadge v-if="!isAllowedToPerformPremiumAction()"></UpgradeBadge>
|
||||||
<EstimatedTimeProgress
|
<EstimatedTimeProgress
|
||||||
v-else-if="project.estimated_time"
|
v-else-if="project.estimated_time"
|
||||||
:estimated="project.estimated_time"
|
:estimated="project.estimated_time"
|
||||||
:current="project.spent_time"></EstimatedTimeProgress>
|
:current="project.spent_time"></EstimatedTimeProgress>
|
||||||
<span v-else> -- </span>
|
<span v-else class="text-text-tertiary"> -- </span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="showBillableRate"
|
v-if="showBillableRate"
|
||||||
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
|
class="whitespace-nowrap px-3 py-4 text-sm text-text-primary">
|
||||||
{{ billableRateInfo }}
|
<span v-if="billableRateInfo">{{ billableRateInfo }}</span>
|
||||||
|
<span v-else class="text-text-tertiary">--</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1.5 items-center font-medium">
|
class="whitespace-nowrap px-3 py-4 text-sm text-text-primary flex space-x-1.5 items-center font-medium">
|
||||||
<template v-if="project.is_archived">
|
<template v-if="project.is_archived">
|
||||||
<ArchiveBoxIcon class="w-4 text-icon-default"></ArchiveBoxIcon>
|
<ArchiveBoxIcon class="w-4 text-icon-default"></ArchiveBoxIcon>
|
||||||
<span>Archived</span>
|
<span>Archived</span>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import VChart, { THEME_KEY } from 'vue-echarts';
|
import VChart, { THEME_KEY } from 'vue-echarts';
|
||||||
import { computed, provide, inject, shallowRef, type ComputedRef } from 'vue';
|
import { computed, provide, inject, shallowRef, type ComputedRef } from 'vue';
|
||||||
import LinearGradient from 'zrender/lib/graphic/LinearGradient';
|
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 { use } from 'echarts/core';
|
||||||
import { CanvasRenderer } from 'echarts/renderers';
|
import { CanvasRenderer } from 'echarts/renderers';
|
||||||
import { BarChart } from 'echarts/charts';
|
import { BarChart } from 'echarts/charts';
|
||||||
@@ -137,7 +137,7 @@ const option = computed(() => ({
|
|||||||
type: 'bar',
|
type: 'bar',
|
||||||
tooltip: {
|
tooltip: {
|
||||||
valueFormatter: (value: number) => {
|
valueFormatter: (value: number) => {
|
||||||
return formatHumanReadableDuration(
|
return formatReportingDuration(
|
||||||
value,
|
value,
|
||||||
organization?.value?.interval_format,
|
organization?.value?.interval_format,
|
||||||
organization?.value?.number_format
|
organization?.value?.number_format
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
import { SaveIcon } from 'lucide-vue-next';
|
import { SaveIcon } from 'lucide-vue-next';
|
||||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||||
import {
|
import {
|
||||||
formatHumanReadableDuration,
|
formatReportingDuration,
|
||||||
getDayJsInstance,
|
getDayJsInstance,
|
||||||
getLocalizedDayJs,
|
getLocalizedDayJs,
|
||||||
} from '@/packages/ui/src/utils/time';
|
} from '@/packages/ui/src/utils/time';
|
||||||
@@ -426,7 +426,7 @@ const tableData = computed(() => {
|
|||||||
class="justify-end flex items-center font-medium"
|
class="justify-end flex items-center font-medium"
|
||||||
:class="!showBillableRate ? 'pr-6' : ''">
|
:class="!showBillableRate ? 'pr-6' : ''">
|
||||||
{{
|
{{
|
||||||
formatHumanReadableDuration(
|
formatReportingDuration(
|
||||||
aggregatedTableTimeEntries.seconds,
|
aggregatedTableTimeEntries.seconds,
|
||||||
organization?.interval_format,
|
organization?.interval_format,
|
||||||
organization?.number_format
|
organization?.number_format
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
TitleComponent,
|
TitleComponent,
|
||||||
TooltipComponent,
|
TooltipComponent,
|
||||||
} from 'echarts/components';
|
} 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 { useCssVariable } from '@/packages/ui/src';
|
||||||
import type { Organization } from '@/packages/api/src';
|
import type { Organization } from '@/packages/api/src';
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ const option = computed(() => ({
|
|||||||
},
|
},
|
||||||
tooltip: {
|
tooltip: {
|
||||||
valueFormatter: (value: number) => {
|
valueFormatter: (value: number) => {
|
||||||
return formatHumanReadableDuration(
|
return formatReportingDuration(
|
||||||
value,
|
value,
|
||||||
organization?.value?.interval_format,
|
organization?.value?.interval_format,
|
||||||
organization?.value?.number_format
|
organization?.value?.number_format
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<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 { formatCents } from '@/packages/ui/src/utils/money';
|
||||||
import GroupedItemsCountButton from '@/packages/ui/src/GroupedItemsCountButton.vue';
|
import GroupedItemsCountButton from '@/packages/ui/src/GroupedItemsCountButton.vue';
|
||||||
import { ref, inject, type ComputedRef } from 'vue';
|
import { ref, inject, type ComputedRef } from 'vue';
|
||||||
@@ -44,7 +44,7 @@ const organization = inject<ComputedRef<Organization>>('organization');
|
|||||||
</div>
|
</div>
|
||||||
<div class="justify-end flex items-center" :class="!showCost ? 'pr-6' : ''">
|
<div class="justify-end flex items-center" :class="!showCost ? 'pr-6' : ''">
|
||||||
{{
|
{{
|
||||||
formatHumanReadableDuration(
|
formatReportingDuration(
|
||||||
entry.seconds,
|
entry.seconds,
|
||||||
organization?.interval_format,
|
organization?.interval_format,
|
||||||
organization?.number_format
|
organization?.number_format
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ defineProps<{
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="rounded-lg bg-card-background border-card-border shadow-card border px-3.5 py-2.5">
|
<div class="rounded-lg bg-card-background border-card-border shadow-card border px-3.5 py-2.5">
|
||||||
<dt class="font-semibold text-sm text-text-secondary">{{ title }}</dt>
|
<dt class="font-medium text-sm text-text-secondary">{{ title }}</dt>
|
||||||
<dd class="text-xl text-text-primary pt-1 font-semibold">
|
<dd class="text-xl text-text-primary pt-1 font-medium">
|
||||||
{{ value ?? '--' }}
|
{{ value ?? '--' }}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ defineProps<{
|
|||||||
<div class="items-center justify-center flex-1 hidden @2xs:flex">
|
<div class="items-center justify-center flex-1 hidden @2xs:flex">
|
||||||
<DayOverviewCardChart :history="history"></DayOverviewCardChart>
|
<DayOverviewCardChart :history="history"></DayOverviewCardChart>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex text-sm items-center justify-center text-text-secondary min-w-[65px]">
|
<div class="flex text-sm items-center justify-center text-text-primary min-w-[65px]">
|
||||||
{{
|
{{
|
||||||
formatHumanReadableDuration(
|
formatHumanReadableDuration(
|
||||||
duration,
|
duration,
|
||||||
|
|||||||
@@ -47,9 +47,9 @@ async function startTaskTimer() {
|
|||||||
<template>
|
<template>
|
||||||
<div class="px-3.5 py-2 grid grid-cols-5">
|
<div class="px-3.5 py-2 grid grid-cols-5">
|
||||||
<div class="col-span-4">
|
<div class="col-span-4">
|
||||||
<p class="text-text-secondary text-sm pb-1.5 truncate">
|
<p class="text-text-primary text-sm pb-1.5 truncate">
|
||||||
<span v-if="timeEntry.description"> {{ timeEntry.description }}</span>
|
<span v-if="timeEntry.description"> {{ timeEntry.description }}</span>
|
||||||
<span v-else>No description</span>
|
<span v-else class="text-text-secondary">No description</span>
|
||||||
</p>
|
</p>
|
||||||
<ProjectBadge size="base" class="min-w-0 max-w-full" :color="project?.color">
|
<ProjectBadge size="base" class="min-w-0 max-w-full" :color="project?.color">
|
||||||
<div class="flex items-center lg:space-x-0.5 min-w-0">
|
<div class="flex items-center lg:space-x-0.5 min-w-0">
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ const { data: latestTeamActivity, isLoading } = useQuery({
|
|||||||
class="text-center flex flex-1 justify-center items-center">
|
class="text-center flex flex-1 justify-center items-center">
|
||||||
<div>
|
<div>
|
||||||
<UserGroupIcon class="w-8 text-icon-default inline pb-2"></UserGroupIcon>
|
<UserGroupIcon class="w-8 text-icon-default inline pb-2"></UserGroupIcon>
|
||||||
<h3 class="text-text-primary font-semibold text-sm">Invite your co-workers</h3>
|
<h3 class="text-text-primary font-medium text-sm">Invite your co-workers</h3>
|
||||||
<p class="pb-5 text-sm">You can invite your entire team.</p>
|
<p class="pb-5 text-sm">You can invite your entire team.</p>
|
||||||
<SecondaryButton @click="router.visit(route('members'))"
|
<SecondaryButton @click="router.visit(route('members'))"
|
||||||
>Go to Members
|
>Go to Members
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ defineProps<{
|
|||||||
<div class="col-span-2">
|
<div class="col-span-2">
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<p
|
<p
|
||||||
class="text-xs min-w-0 overflow-ellipsis overflow-hidden flex-1 text-text-secondary">
|
class="text-sm font-medium min-w-0 overflow-ellipsis overflow-hidden flex-1 text-text-primary">
|
||||||
{{ name }}
|
{{ name }}
|
||||||
</p>
|
</p>
|
||||||
<div v-if="working" class="flex space-x-1.5 items-center justify-end">
|
<div v-if="working" class="flex space-x-1.5 items-center justify-end">
|
||||||
@@ -20,11 +20,11 @@ defineProps<{
|
|||||||
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-500 opacity-75"></span>
|
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-500 opacity-75"></span>
|
||||||
<span class="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
|
<span class="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
|
||||||
</span>
|
</span>
|
||||||
<span class="text-green-500 font-medium text-sm block pb-0.5"> working </span>
|
<span class="text-green-500 text-sm block pb-0.5"> working </span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="text-text-secondary text-sm font-medium text-ellipsis whitespace-nowrap max-w-full overflow-hidden">
|
class="text-text-secondary text-sm text-ellipsis whitespace-nowrap max-w-full overflow-hidden">
|
||||||
{{ description }}
|
{{ description }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import CardTitle from '@/packages/ui/src/CardTitle.vue';
|
|||||||
import LinearGradient from 'zrender/lib/graphic/LinearGradient';
|
import LinearGradient from 'zrender/lib/graphic/LinearGradient';
|
||||||
import ProjectsChartCard from '@/Components/Dashboard/ProjectsChartCard.vue';
|
import ProjectsChartCard from '@/Components/Dashboard/ProjectsChartCard.vue';
|
||||||
import ThisWeekReportingTable from '@/Components/Dashboard/ThisWeekReportingTable.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 { formatCents } from '@/packages/ui/src/utils/money';
|
||||||
import { getWeekStart } from '@/packages/ui/src/utils/settings';
|
import { getWeekStart } from '@/packages/ui/src/utils/settings';
|
||||||
import { useCssVariable } from '@/packages/ui/src';
|
import { useCssVariable } from '@/packages/ui/src';
|
||||||
@@ -223,7 +223,7 @@ const option = computed(() => {
|
|||||||
type: 'bar',
|
type: 'bar',
|
||||||
tooltip: {
|
tooltip: {
|
||||||
valueFormatter: (value: number) => {
|
valueFormatter: (value: number) => {
|
||||||
return formatHumanReadableDuration(
|
return formatReportingDuration(
|
||||||
value,
|
value,
|
||||||
organization?.value?.interval_format,
|
organization?.value?.interval_format,
|
||||||
organization?.value?.number_format
|
organization?.value?.number_format
|
||||||
@@ -252,7 +252,7 @@ const option = computed(() => {
|
|||||||
title="Spent Time"
|
title="Spent Time"
|
||||||
:value="
|
:value="
|
||||||
totalWeeklyTime
|
totalWeeklyTime
|
||||||
? formatHumanReadableDuration(
|
? formatReportingDuration(
|
||||||
totalWeeklyTime,
|
totalWeeklyTime,
|
||||||
organization?.interval_format,
|
organization?.interval_format,
|
||||||
organization?.number_format
|
organization?.number_format
|
||||||
@@ -263,7 +263,7 @@ const option = computed(() => {
|
|||||||
title="Billable Time"
|
title="Billable Time"
|
||||||
:value="
|
:value="
|
||||||
totalWeeklyBillableTime
|
totalWeeklyBillableTime
|
||||||
? formatHumanReadableDuration(
|
? formatReportingDuration(
|
||||||
totalWeeklyBillableTime,
|
totalWeeklyBillableTime,
|
||||||
organization?.interval_format,
|
organization?.interval_format,
|
||||||
organization?.number_format
|
organization?.number_format
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue';
|
import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue';
|
||||||
import ReportingGroupBySelect from '@/Components/Common/Reporting/ReportingGroupBySelect.vue';
|
import ReportingGroupBySelect from '@/Components/Common/Reporting/ReportingGroupBySelect.vue';
|
||||||
import {
|
import {
|
||||||
formatHumanReadableDuration,
|
formatReportingDuration,
|
||||||
getDayJsInstance,
|
getDayJsInstance,
|
||||||
getLocalizedDayJs,
|
getLocalizedDayJs,
|
||||||
} from '@/packages/ui/src/utils/time';
|
} from '@/packages/ui/src/utils/time';
|
||||||
@@ -174,7 +174,7 @@ const showBillableRate = computed(() => {
|
|||||||
class="justify-end flex items-center font-medium"
|
class="justify-end flex items-center font-medium"
|
||||||
:class="!showBillableRate ? 'pr-6' : ''">
|
:class="!showBillableRate ? 'pr-6' : ''">
|
||||||
{{
|
{{
|
||||||
formatHumanReadableDuration(
|
formatReportingDuration(
|
||||||
aggregatedTableTimeEntries.seconds,
|
aggregatedTableTimeEntries.seconds,
|
||||||
organization?.interval_format,
|
organization?.interval_format,
|
||||||
organization?.number_format
|
organization?.number_format
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const open = useSessionStorage('nav-collapse-state-' + props.title, true);
|
|||||||
<CollapsibleRoot v-else v-model:open="open"
|
<CollapsibleRoot v-else v-model:open="open"
|
||||||
><CollapsibleTrigger class="w-full group py-0.5">
|
><CollapsibleTrigger class="w-full group py-0.5">
|
||||||
<div
|
<div
|
||||||
class="text-text-secondary group-hover:text-text-primary group-hover:bg-menu-active group flex gap-x-2 rounded-md transition leading-6 py-0.5 px-2 font-medium text-sm items-center justify-between">
|
class="text-text-secondary group-hover:text-text-primary group-hover:bg-menu-active group flex gap-x-2 rounded-md transition leading-6 py-0.5 px-2 font-regular text-sm items-center justify-between">
|
||||||
<div class="flex items-center gap-x-2">
|
<div class="flex items-center gap-x-2">
|
||||||
<component
|
<component
|
||||||
:is="icon"
|
:is="icon"
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
import FormSection from '@/Components/FormSection.vue';
|
import FormSection from '@/Components/FormSection.vue';
|
||||||
import { Field, FieldLabel, FieldDescription } from '@/packages/ui/src/field';
|
import { Field, FieldLabel, FieldDescription } from '@/packages/ui/src/field';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/packages/ui/src';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/packages/ui/src';
|
||||||
|
import { Checkbox } from '@/packages/ui/src';
|
||||||
import { usePreferredColorScheme } from '@vueuse/core';
|
import { usePreferredColorScheme } from '@vueuse/core';
|
||||||
import { themeSetting } from '@/utils/theme';
|
import { themeSetting } from '@/utils/theme';
|
||||||
|
import { groupSimilarTimeEntriesSetting } from '@/utils/timeEntryGrouping';
|
||||||
|
|
||||||
const preferredColor = usePreferredColorScheme();
|
const preferredColor = usePreferredColorScheme();
|
||||||
</script>
|
</script>
|
||||||
@@ -15,6 +17,7 @@ const preferredColor = usePreferredColorScheme();
|
|||||||
<template #description> Choose how you want solidtime to look on your device </template>
|
<template #description> Choose how you want solidtime to look on your device </template>
|
||||||
|
|
||||||
<template #form>
|
<template #form>
|
||||||
|
<!-- Theme -->
|
||||||
<Field class="col-span-6 sm:col-span-4">
|
<Field class="col-span-6 sm:col-span-4">
|
||||||
<FieldLabel for="theme">Theme</FieldLabel>
|
<FieldLabel for="theme">Theme</FieldLabel>
|
||||||
<Select id="theme" v-model="themeSetting">
|
<Select id="theme" v-model="themeSetting">
|
||||||
@@ -31,6 +34,14 @@ const preferredColor = usePreferredColorScheme();
|
|||||||
System default: {{ preferredColor }}
|
System default: {{ preferredColor }}
|
||||||
</FieldDescription>
|
</FieldDescription>
|
||||||
</Field>
|
</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>
|
</template>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -390,6 +390,7 @@ async function downloadExport(format: ExportFormat) {
|
|||||||
:organization-billable-rate="organization?.billable_rate ?? null"
|
:organization-billable-rate="organization?.billable_rate ?? null"
|
||||||
:duplicate-time-entry="() => createTimeEntry(entry)"
|
:duplicate-time-entry="() => createTimeEntry(entry)"
|
||||||
:members="members"
|
:members="members"
|
||||||
|
is-report
|
||||||
show-date
|
show-date
|
||||||
show-member
|
show-member
|
||||||
:time-entry="entry"
|
:time-entry="entry"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import MainContainer from '@/packages/ui/src/MainContainer.vue';
|
|||||||
import PageTitle from '@/Components/Common/PageTitle.vue';
|
import PageTitle from '@/Components/Common/PageTitle.vue';
|
||||||
import { ChartBarIcon } from '@heroicons/vue/20/solid';
|
import { ChartBarIcon } from '@heroicons/vue/20/solid';
|
||||||
import ReportingChart from '@/Components/Common/Reporting/ReportingChart.vue';
|
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 ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue';
|
||||||
import ReportingPieChart from '@/Components/Common/Reporting/ReportingPieChart.vue';
|
import ReportingPieChart from '@/Components/Common/Reporting/ReportingPieChart.vue';
|
||||||
import { formatCents } from '@/packages/ui/src/utils/money';
|
import { formatCents } from '@/packages/ui/src/utils/money';
|
||||||
@@ -231,7 +231,7 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="justify-end flex items-center font-medium">
|
<div class="justify-end flex items-center font-medium">
|
||||||
{{
|
{{
|
||||||
formatHumanReadableDuration(
|
formatReportingDuration(
|
||||||
aggregatedTableTimeEntries.seconds,
|
aggregatedTableTimeEntries.seconds,
|
||||||
reportIntervalFormat,
|
reportIntervalFormat,
|
||||||
reportNumberFormat
|
reportNumberFormat
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import FormSection from '@/Components/FormSection.vue';
|
import FormSection from '@/Components/FormSection.vue';
|
||||||
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
||||||
import { onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import { Field, FieldLabel } from '@/packages/ui/src/field';
|
import { Field, FieldDescription, FieldLabel } from '@/packages/ui/src/field';
|
||||||
import type { UpdateOrganizationBody } from '@/packages/api/src';
|
import type { UpdateOrganizationBody } from '@/packages/api/src';
|
||||||
import { useOrganizationStore } from '@/utils/useOrganization';
|
import { useOrganizationStore } from '@/utils/useOrganization';
|
||||||
import { storeToRefs } from 'pinia';
|
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() {
|
async function submit() {
|
||||||
mutation.mutate(form.value);
|
mutation.mutate(form.value);
|
||||||
}
|
}
|
||||||
@@ -149,6 +155,12 @@ async function submit() {
|
|||||||
>
|
>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</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>
|
</Field>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { useElementVisibility } from '@vueuse/core';
|
|||||||
import { ClockIcon } from '@heroicons/vue/20/solid';
|
import { ClockIcon } from '@heroicons/vue/20/solid';
|
||||||
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
|
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
|
||||||
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
|
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
|
||||||
|
import { groupSimilarTimeEntriesSetting } from '@/utils/timeEntryGrouping';
|
||||||
import { useTasksQuery } from '@/utils/useTasksQuery';
|
import { useTasksQuery } from '@/utils/useTasksQuery';
|
||||||
import { useProjectsQuery } from '@/utils/useProjectsQuery';
|
import { useProjectsQuery } from '@/utils/useProjectsQuery';
|
||||||
import TimeEntryGroupedTable from '@/packages/ui/src/TimeEntry/TimeEntryGroupedTable.vue';
|
import TimeEntryGroupedTable from '@/packages/ui/src/TimeEntry/TimeEntryGroupedTable.vue';
|
||||||
@@ -151,6 +152,7 @@ function deleteSelected() {
|
|||||||
:tasks="tasks"
|
:tasks="tasks"
|
||||||
:currency="getOrganizationCurrencyString()"
|
:currency="getOrganizationCurrencyString()"
|
||||||
:time-entries="timeEntries"
|
:time-entries="timeEntries"
|
||||||
|
:group-similar-time-entries="groupSimilarTimeEntriesSetting"
|
||||||
:tags="tags"></TimeEntryGroupedTable>
|
:tags="tags"></TimeEntryGroupedTable>
|
||||||
<div v-if="isPending" class="flex justify-center items-center py-12">
|
<div v-if="isPending" class="flex justify-center items-center py-12">
|
||||||
<LoadingSpinner></LoadingSpinner>
|
<LoadingSpinner></LoadingSpinner>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@solidtime/ui",
|
"name": "@solidtime/ui",
|
||||||
"version": "0.0.17",
|
"version": "0.0.21",
|
||||||
"description": "Package containing the solidtime ui components",
|
"description": "Package containing the solidtime ui components",
|
||||||
"main": "./dist/solidtime-ui-lib.umd.cjs",
|
"main": "./dist/solidtime-ui-lib.umd.cjs",
|
||||||
"module": "./dist/solidtime-ui-lib.js",
|
"module": "./dist/solidtime-ui-lib.js",
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const sizeClasses = {
|
|||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
:class="
|
:class="
|
||||||
twMerge(
|
twMerge(
|
||||||
'bg-button-secondary-background border border-button-secondary-border hover:bg-button-secondary-background-hover shadow-sm transition text-text-primary rounded-lg font-semibold inline-flex items-center space-x-1.5 focus-visible:outline-none focus-visible:border-transparent focus-visible:ring-2 focus-visible:ring-ring focus:border-transparent disabled:opacity-25 ease-in-out',
|
'bg-button-secondary-background border border-button-secondary-border hover:bg-button-secondary-background-hover shadow-sm transition text-text-primary rounded-lg font-medium inline-flex items-center space-x-1.5 focus-visible:outline-none focus-visible:border-transparent focus-visible:ring-2 focus-visible:ring-ring focus:border-transparent disabled:opacity-25 ease-in-out',
|
||||||
sizeClasses[props.size],
|
sizeClasses[props.size],
|
||||||
props.class
|
props.class
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '..';
|
|||||||
import type { DayEvent, ActivityBox } from './calendarTypes';
|
import type { DayEvent, ActivityBox } from './calendarTypes';
|
||||||
import type { WindowActivityInPeriod } from './activityTypes';
|
import type { WindowActivityInPeriod } from './activityTypes';
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
dayStr: string;
|
dayStr: string;
|
||||||
totalGridHeight: number;
|
totalGridHeight: number;
|
||||||
hasActivityStatus: boolean;
|
hasActivityStatus: boolean;
|
||||||
@@ -34,6 +34,8 @@ defineProps<{
|
|||||||
getActivityBoxActivities: (box: ActivityBox) => WindowActivityInPeriod[];
|
getActivityBoxActivities: (box: ActivityBox) => WindowActivityInPeriod[];
|
||||||
getActivityPercentage: (count: number, total: number) => string;
|
getActivityPercentage: (count: number, total: number) => string;
|
||||||
getActivityText: (activity: WindowActivityInPeriod) => string;
|
getActivityText: (activity: WindowActivityInPeriod) => string;
|
||||||
|
getTopActivity: (box: ActivityBox) => WindowActivityInPeriod | null;
|
||||||
|
isDayView: boolean;
|
||||||
|
|
||||||
// Selection
|
// Selection
|
||||||
showSelection: boolean;
|
showSelection: boolean;
|
||||||
@@ -46,6 +48,16 @@ defineProps<{
|
|||||||
selectionEndHeight: number;
|
selectionEndHeight: number;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
function isUncoveredByEvents(abox: ActivityBox): boolean {
|
||||||
|
return !props.dayEvents.some((de) => {
|
||||||
|
const eTop = de.top;
|
||||||
|
const eBottom = de.top + de.height;
|
||||||
|
const aTop = abox.top;
|
||||||
|
const aBottom = abox.top + abox.height;
|
||||||
|
return eTop < aBottom && eBottom > aTop;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'event-pointerdown', event: PointerEvent, dayEvent: DayEvent): void;
|
(e: 'event-pointerdown', event: PointerEvent, dayEvent: DayEvent): void;
|
||||||
(e: 'event-keydown-enter', dayEvent: DayEvent): void;
|
(e: 'event-keydown-enter', dayEvent: DayEvent): void;
|
||||||
@@ -55,6 +67,7 @@ const emit = defineEmits<{
|
|||||||
dayEvent: DayEvent,
|
dayEvent: DayEvent,
|
||||||
edge: 'start' | 'end'
|
edge: 'start' | 'end'
|
||||||
): void;
|
): void;
|
||||||
|
(e: 'activity-pointerdown', event: PointerEvent): void;
|
||||||
}>();
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -63,16 +76,20 @@ const emit = defineEmits<{
|
|||||||
class="fc-timegrid-col relative border-r border-border bg-transparent pointer-events-none"
|
class="fc-timegrid-col relative border-r border-border bg-transparent pointer-events-none"
|
||||||
:class="{
|
:class="{
|
||||||
'has-activity-status': hasActivityStatus,
|
'has-activity-status': hasActivityStatus,
|
||||||
|
'activity-expanded': hasActivityStatus && isDayView,
|
||||||
}"
|
}"
|
||||||
:data-date="dayStr"
|
:data-date="dayStr"
|
||||||
:style="{ height: totalGridHeight + 'px' }">
|
:style="{ height: totalGridHeight + 'px' }">
|
||||||
<div
|
<div
|
||||||
class="absolute inset-y-0 left-0.5 right-0.5"
|
class="absolute inset-y-0 left-0.5 right-0.5"
|
||||||
:class="{ 'fc-events-inset': hasActivityStatus }">
|
:class="{
|
||||||
|
'fc-events-inset': hasActivityStatus && !isDayView,
|
||||||
|
'fc-events-inset-expanded': hasActivityStatus && isDayView,
|
||||||
|
}">
|
||||||
<div
|
<div
|
||||||
v-for="dayEvent in dayEvents"
|
v-for="dayEvent in dayEvents"
|
||||||
:key="dayEvent.event.id"
|
:key="dayEvent.event.id"
|
||||||
class="fc-event group pointer-events-auto rounded-sm text-xs cursor-pointer shadow-card overflow-hidden border border-border touch-none select-none hover:shadow-dropdown focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
|
class="fc-event group pointer-events-auto rounded-sm text-xs cursor-pointer shadow-card border border-border touch-none select-none hover:shadow-dropdown focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
|
||||||
:class="[
|
:class="[
|
||||||
getEventOpacityClass(dayEvent, dayStr),
|
getEventOpacityClass(dayEvent, dayStr),
|
||||||
{
|
{
|
||||||
@@ -91,9 +108,7 @@ const emit = defineEmits<{
|
|||||||
:aria-label="dayEvent.event.title"
|
:aria-label="dayEvent.event.title"
|
||||||
role="button"
|
role="button"
|
||||||
@pointerdown="emit('event-pointerdown', $event, dayEvent)"
|
@pointerdown="emit('event-pointerdown', $event, dayEvent)"
|
||||||
@keydown.enter.prevent="
|
@keydown.enter.prevent="emit('event-keydown-enter', dayEvent)">
|
||||||
!dayEvent.event.isRunning && emit('event-keydown-enter', dayEvent)
|
|
||||||
">
|
|
||||||
<div
|
<div
|
||||||
v-if="!dayEvent.isClippedStart"
|
v-if="!dayEvent.isClippedStart"
|
||||||
class="fc-event-resizer fc-event-resizer-start absolute z-[99] w-full h-3 left-0 top-[-2px] cursor-row-resize flex items-center justify-center opacity-0 group-hover:opacity-100"
|
class="fc-event-resizer fc-event-resizer-start absolute z-[99] w-full h-3 left-0 top-[-2px] cursor-row-resize flex items-center justify-center opacity-0 group-hover:opacity-100"
|
||||||
@@ -122,15 +137,47 @@ const emit = defineEmits<{
|
|||||||
class="fc-timegrid-now-indicator-line absolute left-0 right-0 border-t-2 border-red-500 z-50 pointer-events-none"
|
class="fc-timegrid-now-indicator-line absolute left-0 right-0 border-t-2 border-red-500 z-50 pointer-events-none"
|
||||||
:style="{ top: nowIndicatorTop + 'px' }"></div>
|
:style="{ top: nowIndicatorTop + 'px' }"></div>
|
||||||
|
|
||||||
<TooltipProvider :delay-duration="0">
|
<TooltipProvider :disable-hoverable-content="true" :delay-duration="0">
|
||||||
<Tooltip v-for="(abox, ai) in activityBoxes" :key="'activity-' + ai">
|
<Tooltip v-for="(abox, ai) in activityBoxes" :key="'activity-' + ai">
|
||||||
<TooltipTrigger as-child>
|
<TooltipTrigger as-child>
|
||||||
<div
|
<div
|
||||||
class="activity-status-box"
|
class="activity-status-box"
|
||||||
:class="abox.isIdle ? 'idle' : 'active'"
|
:class="[
|
||||||
:style="{ top: abox.top + 'px', height: abox.height + 'px' }"></div>
|
abox.isIdle ? 'idle' : 'active',
|
||||||
|
{
|
||||||
|
'activity-status-box-expanded': isDayView,
|
||||||
|
'activity-status-box-uncovered':
|
||||||
|
!isDayView &&
|
||||||
|
!abox.isIdle &&
|
||||||
|
getTopActivity(abox) &&
|
||||||
|
isUncoveredByEvents(abox),
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
:style="{ top: abox.top + 'px', height: abox.height + 'px' }"
|
||||||
|
@pointerdown="emit('activity-pointerdown', $event)">
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
!abox.isIdle &&
|
||||||
|
getTopActivity(abox) &&
|
||||||
|
abox.height >= 16 &&
|
||||||
|
(isDayView || isUncoveredByEvents(abox))
|
||||||
|
"
|
||||||
|
class="activity-status-content">
|
||||||
|
<img
|
||||||
|
v-if="getTopActivity(abox)?.icon"
|
||||||
|
:src="getTopActivity(abox)!.icon!"
|
||||||
|
:alt="getTopActivity(abox)!.appName"
|
||||||
|
class="activity-status-icon" />
|
||||||
|
<div v-else class="activity-status-icon-fallback">
|
||||||
|
{{ getTopActivity(abox)!.appName.charAt(0).toUpperCase() }}
|
||||||
|
</div>
|
||||||
|
<span class="activity-status-label">
|
||||||
|
{{ getTopActivity(abox)!.label || getTopActivity(abox)!.appName }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right" :side-offset="8">
|
<TooltipContent :side="isDayView ? 'right' : 'left'" :side-offset="8">
|
||||||
<template v-if="getActivityBoxActivities(abox).length === 0">
|
<template v-if="getActivityBoxActivities(abox).length === 0">
|
||||||
{{ getActivityBoxLabel(abox) }}
|
{{ getActivityBoxLabel(abox) }}
|
||||||
</template>
|
</template>
|
||||||
@@ -271,13 +318,99 @@ const emit = defineEmits<{
|
|||||||
background-color: rgba(156, 163, 175, 0.5);
|
background-color: rgba(156, 163, 175, 0.5);
|
||||||
}
|
}
|
||||||
.activity-status-box.active::before {
|
.activity-status-box.active::before {
|
||||||
background-color: rgba(34, 197, 94, 0.3);
|
background-color: rgba(14, 165, 233, 0.3);
|
||||||
}
|
}
|
||||||
.activity-status-box.active:hover::before {
|
.activity-status-box.active:hover::before {
|
||||||
background-color: rgba(34, 197, 94, 1);
|
background-color: rgba(14, 165, 233, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Uncovered activity boxes in week view — fill column width */
|
||||||
|
.activity-status-box-uncovered {
|
||||||
|
width: calc(100% - 4px);
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.activity-status-box-uncovered::before {
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
.activity-status-box-uncovered.active::before {
|
||||||
|
background-color: rgba(14, 165, 233, 0.12);
|
||||||
|
}
|
||||||
|
.activity-status-box-uncovered.active:hover::before {
|
||||||
|
background-color: rgba(14, 165, 233, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Expanded activity boxes for day view */
|
||||||
|
.activity-status-box-expanded {
|
||||||
|
width: 200px;
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.activity-status-box-expanded::before {
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
.activity-status-box-expanded.idle::before {
|
||||||
|
background-color: rgba(156, 163, 175, 0.08);
|
||||||
|
}
|
||||||
|
.activity-status-box-expanded.idle:hover::before {
|
||||||
|
background-color: rgba(156, 163, 175, 0.2);
|
||||||
|
}
|
||||||
|
.activity-status-box-expanded.active::before {
|
||||||
|
background-color: rgba(14, 165, 233, 0.12);
|
||||||
|
}
|
||||||
|
.activity-status-box-expanded.active:hover::before {
|
||||||
|
background-color: rgba(14, 165, 233, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-status-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-status-icon {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-status-icon-fallback {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background-color: rgba(14, 165, 233, 0.2);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: rgba(14, 165, 233, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-status-label {
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.2;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fc-events-inset {
|
.fc-events-inset {
|
||||||
left: 8px;
|
left: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fc-events-inset-expanded {
|
||||||
|
left: 204px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const emit = defineEmits<{
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex items-center justify-between bg-background px-2 py-1.5">
|
<div class="flex items-center justify-between bg-default-background px-2 py-1.5">
|
||||||
<!-- Left: Navigation -->
|
<!-- Left: Navigation -->
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ const {
|
|||||||
getActivityBoxActivities,
|
getActivityBoxActivities,
|
||||||
getActivityPercentage,
|
getActivityPercentage,
|
||||||
getActivityText,
|
getActivityText,
|
||||||
|
getTopActivity,
|
||||||
} = useActivityBoxes({
|
} = useActivityBoxes({
|
||||||
activityPeriods: () => props.activityPeriods,
|
activityPeriods: () => props.activityPeriods,
|
||||||
viewDays,
|
viewDays,
|
||||||
@@ -280,6 +281,22 @@ watch(showEditTimeEntryModal, (value) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guards slot pointer-down so that clicks which dismiss an open Reka UI
|
||||||
|
* layer (context menu, popover, dialog) don't simultaneously start a
|
||||||
|
* new time-entry selection on the calendar grid.
|
||||||
|
*
|
||||||
|
* Because Reka's DismissableLayer registers its document-level
|
||||||
|
* `pointerdown` listener *without* capture, it fires AFTER the
|
||||||
|
* calendar grid's own handler. That means when this guard runs,
|
||||||
|
* `contextMenuOpen` (and modal refs) still reflect the *open* state.
|
||||||
|
*/
|
||||||
|
function guardedSlotPointerDown(e: PointerEvent) {
|
||||||
|
if (contextMenuOpen.value) return;
|
||||||
|
if (showCreateTimeEntryModal.value || showEditTimeEntryModal.value) return;
|
||||||
|
onSlotPointerDown(e);
|
||||||
|
}
|
||||||
|
|
||||||
const scrollToCurrentTime = () => {
|
const scrollToCurrentTime = () => {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
if (!scrollerRef.value) return;
|
if (!scrollerRef.value) return;
|
||||||
@@ -314,6 +331,18 @@ watch(
|
|||||||
{ deep: true }
|
{ deep: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let hasScrolledOnLoad = false;
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.loading,
|
||||||
|
(loading) => {
|
||||||
|
if (!loading && !hasScrolledOnLoad) {
|
||||||
|
hasScrolledOnLoad = true;
|
||||||
|
scrollToCurrentTime();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
scrollToCurrentTime();
|
scrollToCurrentTime();
|
||||||
emitDatesChange();
|
emitDatesChange();
|
||||||
@@ -465,7 +494,7 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
|
|||||||
<div
|
<div
|
||||||
class="fc-header-scroll flex border-b border-border shrink-0 sticky top-0 z-10 bg-default-background">
|
class="fc-header-scroll flex border-b border-border shrink-0 sticky top-0 z-10 bg-default-background">
|
||||||
<div
|
<div
|
||||||
class="shrink-0 bg-background border-r border-border"
|
class="shrink-0 bg-default-background border-r border-border"
|
||||||
:style="{
|
:style="{
|
||||||
width: TIME_AXIS_WIDTH + 'px',
|
width: TIME_AXIS_WIDTH + 'px',
|
||||||
minWidth: TIME_AXIS_WIDTH + 'px',
|
minWidth: TIME_AXIS_WIDTH + 'px',
|
||||||
@@ -478,7 +507,7 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
|
|||||||
<div
|
<div
|
||||||
v-for="day in viewDays"
|
v-for="day in viewDays"
|
||||||
:key="day.format('YYYY-MM-DD')"
|
:key="day.format('YYYY-MM-DD')"
|
||||||
class="fc-col-header-cell border-r border-b border-border px-2 py-3 bg-default-background text-center"
|
class="fc-col-header-cell border-r border-border px-2 py-3 bg-default-background text-center"
|
||||||
:class="{
|
:class="{
|
||||||
'bg-secondary': isToday(day),
|
'bg-secondary': isToday(day),
|
||||||
'fc-day-today': isToday(day),
|
'fc-day-today': isToday(day),
|
||||||
@@ -497,7 +526,7 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
|
|||||||
<div ref="scrollerRef" class="fc-scroller">
|
<div ref="scrollerRef" class="fc-scroller">
|
||||||
<div class="flex min-w-0">
|
<div class="flex min-w-0">
|
||||||
<div
|
<div
|
||||||
class="shrink-0 bg-background border-r border-border"
|
class="shrink-0 bg-default-background border-r border-border"
|
||||||
:style="{
|
:style="{
|
||||||
width: TIME_AXIS_WIDTH + 'px',
|
width: TIME_AXIS_WIDTH + 'px',
|
||||||
minWidth: TIME_AXIS_WIDTH + 'px',
|
minWidth: TIME_AXIS_WIDTH + 'px',
|
||||||
@@ -514,7 +543,7 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
|
|||||||
:style="{ height: SLOT_HEIGHT + 'px' }">
|
:style="{ height: SLOT_HEIGHT + 'px' }">
|
||||||
<span
|
<span
|
||||||
v-if="slot.isHour"
|
v-if="slot.isHour"
|
||||||
class="fc-timegrid-slot-label-cushion text-[0.8125rem] text-muted-foreground leading-none block">
|
class="fc-timegrid-slot-label-cushion text-[0.8125rem] text-muted-foreground leading-none block font-light">
|
||||||
{{ formatSlotLabel(slot.minutes / 60) }}
|
{{ formatSlotLabel(slot.minutes / 60) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -522,14 +551,32 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
class="flex-1 min-w-0 relative"
|
class="flex-1 min-w-0 relative"
|
||||||
@pointerdown="onSlotPointerDown($event)">
|
@pointerdown="guardedSlotPointerDown($event)">
|
||||||
<div
|
<div
|
||||||
class="bg-background"
|
class="bg-default-background relative"
|
||||||
:style="{ height: totalGridHeight + 'px' }">
|
:style="{ height: totalGridHeight + 'px' }">
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 grid"
|
||||||
|
:style="{
|
||||||
|
gridTemplateColumns:
|
||||||
|
'repeat(' + viewDays.length + ', 1fr)',
|
||||||
|
}">
|
||||||
|
<div
|
||||||
|
v-for="day in viewDays"
|
||||||
|
:key="'bg-' + day.format('YYYY-MM-DD')"
|
||||||
|
:style="
|
||||||
|
isToday(day)
|
||||||
|
? {
|
||||||
|
backgroundColor:
|
||||||
|
'var(--theme-color-default-background)',
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
" />
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
v-for="slot in slots"
|
v-for="slot in slots"
|
||||||
:key="'lane-' + slot.time"
|
:key="'lane-' + slot.time"
|
||||||
class="fc-timegrid-slot fc-timegrid-slot-lane border-t border-border box-border"
|
class="fc-timegrid-slot fc-timegrid-slot-lane border-t border-border box-border relative"
|
||||||
:class="{
|
:class="{
|
||||||
'fc-timegrid-slot-minor border-dotted':
|
'fc-timegrid-slot-minor border-dotted':
|
||||||
!slot.isHour,
|
!slot.isHour,
|
||||||
@@ -581,6 +628,8 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
|
|||||||
:get-activity-box-activities="getActivityBoxActivities"
|
:get-activity-box-activities="getActivityBoxActivities"
|
||||||
:get-activity-percentage="getActivityPercentage"
|
:get-activity-percentage="getActivityPercentage"
|
||||||
:get-activity-text="getActivityText"
|
:get-activity-text="getActivityText"
|
||||||
|
:get-top-activity="getTopActivity"
|
||||||
|
:is-day-view="activeView === 'timeGridDay'"
|
||||||
:show-selection="
|
:show-selection="
|
||||||
isSelecting || showCreateTimeEntryModal
|
isSelecting || showCreateTimeEntryModal
|
||||||
"
|
"
|
||||||
@@ -599,6 +648,7 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
|
|||||||
:selection-height="selectionHeight"
|
:selection-height="selectionHeight"
|
||||||
:selection-end-top="selectionEndTop"
|
:selection-end-top="selectionEndTop"
|
||||||
:selection-end-height="selectionEndHeight"
|
:selection-end-height="selectionEndHeight"
|
||||||
|
@activity-pointerdown="guardedSlotPointerDown"
|
||||||
@event-pointerdown="
|
@event-pointerdown="
|
||||||
(e, dayEvent) =>
|
(e, dayEvent) =>
|
||||||
onEventPointerDown(e, dayEvent.event, dayEvent)
|
onEventPointerDown(e, dayEvent.event, dayEvent)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export interface WindowActivityInPeriod {
|
export interface WindowActivityInPeriod {
|
||||||
appName: string;
|
appName: string;
|
||||||
url: string | null;
|
label: string | null;
|
||||||
count: number;
|
count: number;
|
||||||
icon?: string | null;
|
icon?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,19 +12,13 @@ export function useActivityBoxes(params: {
|
|||||||
calendarSettings: Ref<CalendarSettings>;
|
calendarSettings: Ref<CalendarSettings>;
|
||||||
minutesToPixels: (minutes: number) => number;
|
minutesToPixels: (minutes: number) => number;
|
||||||
}) {
|
}) {
|
||||||
function formatActivityDuration(durationMinutes: number): string {
|
|
||||||
const hours = Math.floor(durationMinutes / 60);
|
|
||||||
const minutes = durationMinutes % 60;
|
|
||||||
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getActivityBoxLabel(box: ActivityBox): string {
|
function getActivityBoxLabel(box: ActivityBox): string {
|
||||||
const periodStart = getLocalizedDayJs(box.period.start);
|
const periodStart = getLocalizedDayJs(box.period.start);
|
||||||
const periodEnd = getLocalizedDayJs(box.period.end);
|
const periodEnd = getLocalizedDayJs(box.period.end);
|
||||||
const durationMinutes = Math.round(periodEnd.diff(periodStart, 'minute', true));
|
const startText = periodStart.format('HH:mm');
|
||||||
const durationText = formatActivityDuration(durationMinutes);
|
const endText = periodEnd.format('HH:mm');
|
||||||
const status = box.isIdle ? 'Idling' : 'Active';
|
const status = box.isIdle ? 'Idling' : 'Active';
|
||||||
return `${status} (${durationText})`;
|
return `${status} (${startText} - ${endText})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getActivityBoxActivities(box: ActivityBox) {
|
function getActivityBoxActivities(box: ActivityBox) {
|
||||||
@@ -37,7 +31,16 @@ export function useActivityBoxes(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getActivityText(activity: WindowActivityInPeriod): string {
|
function getActivityText(activity: WindowActivityInPeriod): string {
|
||||||
return activity.url ? `${activity.appName} - ${activity.url}` : activity.appName;
|
return activity.label ? `${activity.appName} - ${activity.label}` : activity.appName;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTopActivity(box: ActivityBox): WindowActivityInPeriod | null {
|
||||||
|
const activities = box.period.windowActivities;
|
||||||
|
if (!activities || activities.length === 0) return null;
|
||||||
|
return activities.reduce<WindowActivityInPeriod>(
|
||||||
|
(top, a) => (a.count > top.count ? a : top),
|
||||||
|
activities[0]!
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const activityBoxes = computed<ActivityBox[]>(() => {
|
const activityBoxes = computed<ActivityBox[]>(() => {
|
||||||
@@ -99,5 +102,6 @@ export function useActivityBoxes(params: {
|
|||||||
getActivityBoxActivities,
|
getActivityBoxActivities,
|
||||||
getActivityPercentage,
|
getActivityPercentage,
|
||||||
getActivityText,
|
getActivityText,
|
||||||
|
getTopActivity,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { ref, type Ref, type ComputedRef } from 'vue';
|
import { ref, type Ref, type ComputedRef } from 'vue';
|
||||||
import type { Dayjs } from 'dayjs';
|
import type { Dayjs } from 'dayjs';
|
||||||
import type { TimeEntry } from '@/packages/api/src';
|
import type { TimeEntry } from '@/packages/api/src';
|
||||||
import { getDayJsInstance } from '../utils/time';
|
import { getDayJsInstance, getLocalizedDayJsFromMinutes } from '../utils/time';
|
||||||
import { getUserTimezone } from '../utils/settings';
|
|
||||||
import type { CalendarSettings } from './calendarSettings';
|
import type { CalendarSettings } from './calendarSettings';
|
||||||
import type { CalendarEvent } from './calendarTypes';
|
import type { CalendarEvent } from './calendarTypes';
|
||||||
|
|
||||||
@@ -34,11 +34,8 @@ export function useContextMenu(params: {
|
|||||||
const snap = params.calendarSettings.value.snapMinutes;
|
const snap = params.calendarSettings.value.snapMinutes;
|
||||||
const snappedMinutes = Math.floor(minutesFromGridStart / snap) * snap;
|
const snappedMinutes = Math.floor(minutesFromGridStart / snap) * snap;
|
||||||
|
|
||||||
const dayjs = getDayJsInstance();
|
const startLocal = getLocalizedDayJsFromMinutes(date, snappedMinutes);
|
||||||
const startLocal = dayjs(`${date}T00:00:00`)
|
const snappedEnd = getLocalizedDayJsFromMinutes(date, snappedMinutes + snap);
|
||||||
.tz(getUserTimezone(), true)
|
|
||||||
.add(snappedMinutes, 'minute');
|
|
||||||
const snappedEnd = startLocal.add(snap, 'minute');
|
|
||||||
|
|
||||||
return { start: startLocal.utc(), end: snappedEnd.utc() };
|
return { start: startLocal.utc(), end: snappedEnd.utc() };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { computed, ref, onUnmounted, type Ref, type ComputedRef } from 'vue';
|
import { computed, ref, onUnmounted, type Ref, type ComputedRef } from 'vue';
|
||||||
import type { Dayjs } from 'dayjs';
|
import type { Dayjs } from 'dayjs';
|
||||||
import type { TimeEntry } from '@/packages/api/src';
|
import type { TimeEntry } from '@/packages/api/src';
|
||||||
import { getDayJsInstance, getLocalizedDayJs } from '../utils/time';
|
import { getLocalizedDayJs, getLocalizedDayJsFromMinutes } from '../utils/time';
|
||||||
import { getUserTimezone } from '../utils/settings';
|
|
||||||
import type { CalendarSettings } from './calendarSettings';
|
import type { CalendarSettings } from './calendarSettings';
|
||||||
import type { CalendarEvent, DayEvent } from './calendarTypes';
|
import type { CalendarEvent, DayEvent } from './calendarTypes';
|
||||||
import { SLOT_HEIGHT, DRAG_THRESHOLD } from './calendarTypes';
|
import { SLOT_HEIGHT, DRAG_THRESHOLD } from './calendarTypes';
|
||||||
@@ -67,8 +66,7 @@ export function useEventDrag(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (dayEvent.isClippedStart && originDay && ev.timeEntry.end) {
|
if (dayEvent.isClippedStart && originDay && ev.timeEntry.end) {
|
||||||
const dayjs = getDayJsInstance();
|
const dayMidnight = getLocalizedDayJsFromMinutes(originDay, 0);
|
||||||
const dayMidnight = dayjs(`${originDay}T00:00:00`).tz(getUserTimezone(), true);
|
|
||||||
const evStart = getLocalizedDayJs(ev.timeEntry.start);
|
const evStart = getLocalizedDayJs(ev.timeEntry.start);
|
||||||
const eventStartFromGridStart = evStart.diff(dayMidnight, 'minute') - s.startHour * 60;
|
const eventStartFromGridStart = evStart.diff(dayMidnight, 'minute') - s.startHour * 60;
|
||||||
const segmentTopMinutes = (dayEvent.top / SLOT_HEIGHT) * s.slotMinutes;
|
const segmentTopMinutes = (dayEvent.top / SLOT_HEIGHT) * s.slotMinutes;
|
||||||
@@ -154,13 +152,11 @@ export function useEventDrag(params: {
|
|||||||
const lowerBound = startMin - 4 * 60;
|
const lowerBound = startMin - 4 * 60;
|
||||||
const clampedMinutes = Math.max(lowerBound, Math.min(snappedMinutes, s.endHour * 60));
|
const clampedMinutes = Math.max(lowerBound, Math.min(snappedMinutes, s.endHour * 60));
|
||||||
|
|
||||||
const dayjs = getDayJsInstance();
|
const originalSegmentStart = getLocalizedDayJsFromMinutes(
|
||||||
const originalSegmentStart = dayjs(`${savedOriginalDayStr}T00:00:00`)
|
savedOriginalDayStr,
|
||||||
.tz(getUserTimezone(), true)
|
startMin + params.pixelsToMinutesFromMidnight(dragStartEventTop)
|
||||||
.add(startMin + params.pixelsToMinutesFromMidnight(dragStartEventTop), 'minute');
|
);
|
||||||
const newSegmentStart = dayjs(`${targetDateStr}T00:00:00`)
|
const newSegmentStart = getLocalizedDayJsFromMinutes(targetDateStr, clampedMinutes);
|
||||||
.tz(getUserTimezone(), true)
|
|
||||||
.add(clampedMinutes, 'minute');
|
|
||||||
const deltaMs = newSegmentStart.diff(originalSegmentStart);
|
const deltaMs = newSegmentStart.diff(originalSegmentStart);
|
||||||
|
|
||||||
const origStart = getLocalizedDayJs(timeEntry.start);
|
const origStart = getLocalizedDayJs(timeEntry.start);
|
||||||
@@ -240,11 +236,14 @@ export function useEventDrag(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Multi-day: compute actual start/end datetimes, then clip per day
|
// Multi-day: compute actual start/end datetimes, then clip per day
|
||||||
const dayjs = getDayJsInstance();
|
const eventStartAbsolute = getLocalizedDayJsFromMinutes(
|
||||||
const eventStartAbsolute = dayjs(`${dragCurrentDay.value}T00:00:00`)
|
dragCurrentDay.value,
|
||||||
.tz(getUserTimezone(), true)
|
startMin + eventStartOnGrid
|
||||||
.add(startMin + eventStartOnGrid, 'minute');
|
);
|
||||||
const eventEndAbsolute = eventStartAbsolute.add(dragFullDurationMinutes, 'minute');
|
const eventEndAbsolute = getLocalizedDayJsFromMinutes(
|
||||||
|
dragCurrentDay.value,
|
||||||
|
startMin + eventStartOnGrid + dragFullDurationMinutes
|
||||||
|
);
|
||||||
|
|
||||||
const result: Record<string, Record<string, string>> = {};
|
const result: Record<string, Record<string, string>> = {};
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { computed, ref, onUnmounted, type Ref, type ComputedRef } from 'vue';
|
import { computed, ref, onUnmounted, type Ref, type ComputedRef } from 'vue';
|
||||||
import type { Dayjs } from 'dayjs';
|
import type { Dayjs } from 'dayjs';
|
||||||
import type { TimeEntry } from '@/packages/api/src';
|
import type { TimeEntry } from '@/packages/api/src';
|
||||||
import { getDayJsInstance, getLocalizedDayJs } from '../utils/time';
|
import { getDayJsInstance, getLocalizedDayJs, getLocalizedDayJsFromMinutes } from '../utils/time';
|
||||||
import { getUserTimezone } from '../utils/settings';
|
|
||||||
import type { CalendarSettings } from './calendarSettings';
|
import type { CalendarSettings } from './calendarSettings';
|
||||||
import type { CalendarEvent, DayEvent } from './calendarTypes';
|
import type { CalendarEvent, DayEvent } from './calendarTypes';
|
||||||
import { SLOT_HEIGHT } from './calendarTypes';
|
import { SLOT_HEIGHT } from './calendarTypes';
|
||||||
@@ -11,10 +10,6 @@ function snapTo(value: number, step: number): number {
|
|||||||
return Math.round(value / step) * step;
|
return Math.round(value / step) * step;
|
||||||
}
|
}
|
||||||
|
|
||||||
function dayMidnightLocal(dayStr: string): Dayjs {
|
|
||||||
return getDayJsInstance()(`${dayStr}T00:00:00`).tz(getUserTimezone(), true);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useEventResize(params: {
|
export function useEventResize(params: {
|
||||||
calendarSettings: Ref<CalendarSettings>;
|
calendarSettings: Ref<CalendarSettings>;
|
||||||
viewDays: ComputedRef<Dayjs[]>;
|
viewDays: ComputedRef<Dayjs[]>;
|
||||||
@@ -89,7 +84,7 @@ export function useEventResize(params: {
|
|||||||
),
|
),
|
||||||
s.snapMinutes
|
s.snapMinutes
|
||||||
);
|
);
|
||||||
return { start, end: dayMidnightLocal(endDay).add(endMinutes, 'minute') };
|
return { start, end: getLocalizedDayJsFromMinutes(endDay, endMinutes) };
|
||||||
} else {
|
} else {
|
||||||
const end = resizeOriginalEvent.isRunning
|
const end = resizeOriginalEvent.isRunning
|
||||||
? getLocalizedDayJs()
|
? getLocalizedDayJs()
|
||||||
@@ -105,7 +100,7 @@ export function useEventResize(params: {
|
|||||||
params.pixelsToMinutesFromMidnight(resizeCurrentTop.value),
|
params.pixelsToMinutesFromMidnight(resizeCurrentTop.value),
|
||||||
s.snapMinutes
|
s.snapMinutes
|
||||||
);
|
);
|
||||||
return { start: dayMidnightLocal(startDay).add(startMinutes, 'minute'), end };
|
return { start: getLocalizedDayJsFromMinutes(startDay, startMinutes), end };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { computed, ref, onUnmounted, type Ref, type ComputedRef } from 'vue';
|
import { computed, ref, onUnmounted, type Ref, type ComputedRef } from 'vue';
|
||||||
import type { Dayjs } from 'dayjs';
|
import type { Dayjs } from 'dayjs';
|
||||||
import { getDayJsInstance } from '../utils/time';
|
import { getLocalizedDayJsFromMinutes } from '../utils/time';
|
||||||
import { getUserTimezone } from '../utils/settings';
|
|
||||||
import type { CalendarSettings } from './calendarSettings';
|
import type { CalendarSettings } from './calendarSettings';
|
||||||
import { SLOT_HEIGHT } from './calendarTypes';
|
import { SLOT_HEIGHT } from './calendarTypes';
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ export function useSlotSelection(params: {
|
|||||||
function onSlotPointerDown(e: PointerEvent) {
|
function onSlotPointerDown(e: PointerEvent) {
|
||||||
if (e.button !== 0) return;
|
if (e.button !== 0) return;
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
if (target.closest('.fc-event') || target.closest('.activity-status-box')) return;
|
if (target.closest('.fc-event')) return;
|
||||||
|
|
||||||
const dateStr = params.getDayFromClientX(e.clientX);
|
const dateStr = params.getDayFromClientX(e.clientX);
|
||||||
if (!dateStr) return;
|
if (!dateStr) return;
|
||||||
@@ -102,8 +102,6 @@ export function useSlotSelection(params: {
|
|||||||
|
|
||||||
const s = params.calendarSettings.value;
|
const s = params.calendarSettings.value;
|
||||||
const snap = s.snapMinutes;
|
const snap = s.snapMinutes;
|
||||||
const dayjs = getDayJsInstance();
|
|
||||||
|
|
||||||
const startMinutes = params.pixelsToMinutesFromMidnight(selectionTop.value);
|
const startMinutes = params.pixelsToMinutesFromMidnight(selectionTop.value);
|
||||||
const snappedStartMin = Math.floor(startMinutes / snap) * snap;
|
const snappedStartMin = Math.floor(startMinutes / snap) * snap;
|
||||||
|
|
||||||
@@ -138,12 +136,8 @@ export function useSlotSelection(params: {
|
|||||||
if (endMin <= 0) endMin = snap;
|
if (endMin <= 0) endMin = snap;
|
||||||
}
|
}
|
||||||
|
|
||||||
startLocal = dayjs(`${startDateStr}T00:00:00`)
|
startLocal = getLocalizedDayJsFromMinutes(startDateStr, startMin);
|
||||||
.tz(getUserTimezone(), true)
|
endLocal = getLocalizedDayJsFromMinutes(endDateStr, endMin);
|
||||||
.add(startMin, 'minute');
|
|
||||||
endLocal = dayjs(`${endDateStr}T00:00:00`)
|
|
||||||
.tz(getUserTimezone(), true)
|
|
||||||
.add(endMin, 'minute');
|
|
||||||
} else {
|
} else {
|
||||||
const startDateStr = selectionStartDay;
|
const startDateStr = selectionStartDay;
|
||||||
const endMinutes = params.pixelsToMinutesFromMidnight(
|
const endMinutes = params.pixelsToMinutesFromMidnight(
|
||||||
@@ -153,12 +147,8 @@ export function useSlotSelection(params: {
|
|||||||
if (snappedEndMin <= snappedStartMin) {
|
if (snappedEndMin <= snappedStartMin) {
|
||||||
snappedEndMin = snappedStartMin + snap;
|
snappedEndMin = snappedStartMin + snap;
|
||||||
}
|
}
|
||||||
startLocal = dayjs(`${startDateStr}T00:00:00`)
|
startLocal = getLocalizedDayJsFromMinutes(startDateStr, snappedStartMin);
|
||||||
.tz(getUserTimezone(), true)
|
endLocal = getLocalizedDayJsFromMinutes(startDateStr, snappedEndMin);
|
||||||
.add(snappedStartMin, 'minute');
|
|
||||||
endLocal = dayjs(`${startDateStr}T00:00:00`)
|
|
||||||
.tz(getUserTimezone(), true)
|
|
||||||
.add(snappedEndMin, 'minute');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
params.onSelectionComplete(startLocal.utc(), endLocal.utc());
|
params.onSelectionComplete(startLocal.utc(), endLocal.utc());
|
||||||
|
|||||||
@@ -6,10 +6,15 @@ const props = withDefaults(
|
|||||||
defineProps<{
|
defineProps<{
|
||||||
expanded?: boolean;
|
expanded?: boolean;
|
||||||
size?: string;
|
size?: string;
|
||||||
|
/**
|
||||||
|
* Test ID used for Playwright/E2E tests.
|
||||||
|
*/
|
||||||
|
testId?: string;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
expanded: false,
|
expanded: false,
|
||||||
size: 'w-7 h-7',
|
size: 'w-7 h-7',
|
||||||
|
testId: 'grouped_items_count_button',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -23,6 +28,7 @@ const expandedStatusClasses = computed(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<button
|
<button
|
||||||
|
:data-testid="props.testId"
|
||||||
:class="
|
:class="
|
||||||
twMerge(
|
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',
|
'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',
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const props = defineProps<{
|
|||||||
currency: string;
|
currency: string;
|
||||||
enableEstimatedTime: boolean;
|
enableEstimatedTime: boolean;
|
||||||
organizationBillableRate: number | null;
|
organizationBillableRate: number | null;
|
||||||
|
initialProjectName?: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const activeClients = computed(() => {
|
const activeClients = computed(() => {
|
||||||
@@ -34,7 +35,7 @@ const activeClients = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const project = ref<CreateProjectBody>({
|
const project = ref<CreateProjectBody>({
|
||||||
name: '',
|
name: props.initialProjectName ?? '',
|
||||||
color: getRandomColor(),
|
color: getRandomColor(),
|
||||||
client_id: null,
|
client_id: null,
|
||||||
billable_rate: null,
|
billable_rate: null,
|
||||||
|
|||||||
@@ -32,10 +32,10 @@ const displaysPlaceholder = computed(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="relative min-w-0 text-ellipsis whitespace-nowrap overflow-hidden">
|
<div class="relative min-w-0 text-ellipsis whitespace-nowrap overflow-hidden">
|
||||||
<div class="relative text-sm font-medium min-w-0">
|
<div class="relative text-sm min-w-0">
|
||||||
<div
|
<div
|
||||||
:class="[
|
:class="[
|
||||||
'opacity-0 h-4 text-sm whitespace-pre font-medium min-w-0 pl-1.5 @lg:pl-3 pr-1',
|
'opacity-0 h-4 text-sm whitespace-pre min-w-0 pl-1.5 @lg:pl-3 pr-1',
|
||||||
{ 'min-w-[130px]': displaysPlaceholder },
|
{ 'min-w-[130px]': displaysPlaceholder },
|
||||||
]">
|
]">
|
||||||
{{ liveDataValue }}
|
{{ liveDataValue }}
|
||||||
@@ -44,7 +44,7 @@ const displaysPlaceholder = computed(() => {
|
|||||||
data-testid="time_entry_description"
|
data-testid="time_entry_description"
|
||||||
:value="liveDataValue"
|
:value="liveDataValue"
|
||||||
placeholder="Add a description"
|
placeholder="Add a description"
|
||||||
class="absolute px-0 h-full min-w-0 pl-1.5 @lg:pl-3 pr-1 left-0 top-0 w-full text-sm text-text-primary font-medium bg-transparent focus-visible:ring-0 rounded-lg border-0"
|
class="absolute px-0 h-full min-w-0 pl-1.5 @lg:pl-3 pr-1 left-0 top-0 w-full text-sm text-text-primary bg-transparent focus-visible:ring-0 rounded-lg border-0"
|
||||||
@blur="onChange"
|
@blur="onChange"
|
||||||
@input="onInput"
|
@input="onInput"
|
||||||
@keydown.enter="onChange" />
|
@keydown.enter="onChange" />
|
||||||
|
|||||||
@@ -20,24 +20,30 @@ const selectedTimeEntries = defineModel<TimeEntry[]>('selected', {
|
|||||||
default: [],
|
default: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = withDefaults(
|
||||||
timeEntries: TimeEntry[];
|
defineProps<{
|
||||||
projects: Project[];
|
timeEntries: TimeEntry[];
|
||||||
tasks: Task[];
|
projects: Project[];
|
||||||
tags: Tag[];
|
tasks: Task[];
|
||||||
clients: Client[];
|
tags: Tag[];
|
||||||
createTag: (name: string) => Promise<Tag | undefined>;
|
clients: Client[];
|
||||||
updateTimeEntry: (entry: TimeEntry) => void;
|
createTag: (name: string) => Promise<Tag | undefined>;
|
||||||
updateTimeEntries: (ids: string[], changes: Partial<TimeEntry>) => void;
|
updateTimeEntry: (entry: TimeEntry) => void;
|
||||||
deleteTimeEntries: (entries: TimeEntry[]) => void;
|
updateTimeEntries: (ids: string[], changes: Partial<TimeEntry>) => void;
|
||||||
createTimeEntry: (entry: Omit<CreateTimeEntryBody, 'member_id'>) => void;
|
deleteTimeEntries: (entries: TimeEntry[]) => void;
|
||||||
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
|
createTimeEntry: (entry: Omit<CreateTimeEntryBody, 'member_id'>) => void;
|
||||||
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
|
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
|
||||||
currency: string;
|
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
|
||||||
organizationBillableRate: number | null;
|
currency: string;
|
||||||
enableEstimatedTime: boolean;
|
organizationBillableRate: number | null;
|
||||||
canCreateProject: boolean;
|
enableEstimatedTime: boolean;
|
||||||
}>();
|
canCreateProject: boolean;
|
||||||
|
groupSimilarTimeEntries?: boolean;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
groupSimilarTimeEntries: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const groupedTimeEntries = computed(() => {
|
const groupedTimeEntries = computed(() => {
|
||||||
const groupedEntriesByDay: Record<string, TimeEntry[]> = {};
|
const groupedEntriesByDay: Record<string, TimeEntry[]> = {};
|
||||||
@@ -58,6 +64,11 @@ const groupedTimeEntries = computed(() => {
|
|||||||
const newDailyEntries: TimeEntriesGroupedByType[] = [];
|
const newDailyEntries: TimeEntriesGroupedByType[] = [];
|
||||||
|
|
||||||
for (const entry of dailyEntries) {
|
for (const entry of dailyEntries) {
|
||||||
|
if (!props.groupSimilarTimeEntries) {
|
||||||
|
newDailyEntries.push({ ...entry, timeEntries: [entry] });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// check if same entry already exists
|
// check if same entry already exists
|
||||||
const oldEntriesIndex = newDailyEntries.findIndex(
|
const oldEntriesIndex = newDailyEntries.findIndex(
|
||||||
(e) =>
|
(e) =>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import type {
|
|||||||
} from '@/packages/api/src';
|
} from '@/packages/api/src';
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
import { Checkbox } from '@/packages/ui/src';
|
import { Button, Checkbox } from '@/packages/ui/src';
|
||||||
import { FieldLabel } from '../field';
|
import { FieldLabel } from '../field';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -66,7 +66,7 @@ const showMassUpdateModal = ref(false);
|
|||||||
:class="
|
:class="
|
||||||
twMerge(
|
twMerge(
|
||||||
props.class,
|
props.class,
|
||||||
'text-sm py-1.5 font-medium hidden sm:flex border-b border-border-primary items-center space-x-3'
|
'text-sm h-8 font-medium hidden sm:flex border-b border-border-primary items-center space-x-3'
|
||||||
)
|
)
|
||||||
">
|
">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -83,20 +83,24 @@ const showMassUpdateModal = ref(false);
|
|||||||
<FieldLabel v-else for="selectAll" class="text-text-secondary select-none"
|
<FieldLabel v-else for="selectAll" class="text-text-secondary select-none"
|
||||||
>Select All</FieldLabel
|
>Select All</FieldLabel
|
||||||
>
|
>
|
||||||
<button
|
<Button
|
||||||
v-if="selectedTimeEntries.length"
|
v-if="selectedTimeEntries.length"
|
||||||
class="text-text-tertiary flex space-x-1 items-center hover:text-text-secondary transition focus-visible:ring-2 outline-0 focus-visible:text-text-primary focus-visible:ring-ring rounded h-full px-2"
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
class="text-text-tertiary hover:text-text-secondary"
|
||||||
@click="showMassUpdateModal = true">
|
@click="showMassUpdateModal = true">
|
||||||
<PencilSquareIcon class="w-4"></PencilSquareIcon>
|
<PencilSquareIcon class="w-4"></PencilSquareIcon>
|
||||||
<span> Edit </span>
|
<span>Edit</span>
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
v-if="selectedTimeEntries.length"
|
v-if="selectedTimeEntries.length"
|
||||||
class="text-red-400 h-full px-2 space-x-1 items-center flex hover:text-red-500 transition focus-visible:ring-2 outline-0 focus-visible:text-red-500 focus-visible:ring-ring rounded"
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
class="text-red-400 hover:text-red-500 hover:bg-red-500/10"
|
||||||
@click="deleteSelected">
|
@click="deleteSelected">
|
||||||
<TrashIcon class="w-3.5"></TrashIcon>
|
<TrashIcon class="w-3.5"></TrashIcon>
|
||||||
<span> Delete </span>
|
<span>Delete</span>
|
||||||
</button>
|
</Button>
|
||||||
</MainContainer>
|
</MainContainer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -36,15 +36,13 @@ const organization = inject<ComputedRef<Organization>>('organization');
|
|||||||
:class="
|
:class="
|
||||||
twMerge(
|
twMerge(
|
||||||
'text-text-secondary px-1 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:text-text-primary focus-visible:ring-ring focus-visible:bg-tertiary',
|
'text-text-secondary px-1 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:text-text-primary focus-visible:ring-ring focus-visible:bg-tertiary',
|
||||||
showDate
|
showDate ? 'text-xs py-1.5 font-medium' : 'text-sm py-1.5 font-normal',
|
||||||
? 'text-xs py-1.5 font-semibold'
|
|
||||||
: 'text-sm py-1.5 font-medium',
|
|
||||||
organization?.time_format === '12-hours' ? 'w-[160px]' : 'w-[100px]',
|
organization?.time_format === '12-hours' ? 'w-[160px]' : 'w-[100px]',
|
||||||
open && 'border-card-border bg-card-background'
|
open && 'border-card-border bg-card-background'
|
||||||
)
|
)
|
||||||
">
|
">
|
||||||
{{ formatStartEnd(start, end, organization?.time_format) }}
|
{{ formatStartEnd(start, end, organization?.time_format) }}
|
||||||
<span v-if="showDate" class="text-text-tertiary font-medium"
|
<span v-if="showDate" class="text-text-tertiary font-normal"
|
||||||
>{{ formatDateLocalized(start, organization?.date_format) }}
|
>{{ formatDateLocalized(start, organization?.date_format) }}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ const props = defineProps<{
|
|||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
canCreateProject: boolean;
|
canCreateProject: boolean;
|
||||||
enableEstimatedTime: boolean;
|
enableEstimatedTime: boolean;
|
||||||
|
isReport?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{ selected: []; unselected: [] }>();
|
const emit = defineEmits<{ selected: []; unselected: [] }>();
|
||||||
@@ -148,8 +149,7 @@ async function handleDeleteTimeEntry() {
|
|||||||
:task="timeEntry.task_id"
|
:task="timeEntry.task_id"
|
||||||
@changed="updateProjectAndTask"></TimeTrackerProjectTaskDropdown>
|
@changed="updateProjectAndTask"></TimeTrackerProjectTaskDropdown>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div class="hidden @lg:flex items-center space-x-1 @lg:space-x-2 shrink-0">
|
||||||
class="hidden @lg:flex items-center font-medium space-x-1 @lg:space-x-2 shrink-0">
|
|
||||||
<div v-if="showMember && members" class="text-sm px-2">
|
<div v-if="showMember && members" class="text-sm px-2">
|
||||||
{{ memberName }}
|
{{ memberName }}
|
||||||
</div>
|
</div>
|
||||||
@@ -173,6 +173,7 @@ async function handleDeleteTimeEntry() {
|
|||||||
<TimeEntryRowDurationInput
|
<TimeEntryRowDurationInput
|
||||||
:start="timeEntry.start"
|
:start="timeEntry.start"
|
||||||
:end="timeEntry.end"
|
:end="timeEntry.end"
|
||||||
|
:is-report="props.isReport"
|
||||||
@changed="updateStartEndTime"></TimeEntryRowDurationInput>
|
@changed="updateStartEndTime"></TimeEntryRowDurationInput>
|
||||||
<TimeTrackerStartStop
|
<TimeTrackerStartStop
|
||||||
:active="!!(timeEntry.start && !timeEntry.end)"
|
:active="!!(timeEntry.start && !timeEntry.end)"
|
||||||
@@ -197,6 +198,7 @@ async function handleDeleteTimeEntry() {
|
|||||||
<TimeEntryRowDurationInput
|
<TimeEntryRowDurationInput
|
||||||
:start="timeEntry.start"
|
:start="timeEntry.start"
|
||||||
:end="timeEntry.end"
|
:end="timeEntry.end"
|
||||||
|
:is-report="props.isReport"
|
||||||
@changed="updateStartEndTime"></TimeEntryRowDurationInput>
|
@changed="updateStartEndTime"></TimeEntryRowDurationInput>
|
||||||
</div>
|
</div>
|
||||||
<!-- Second row: project/task - tags - billable - start - more -->
|
<!-- Second row: project/task - tags - billable - start - more -->
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import {
|
import {
|
||||||
calculateDifference,
|
calculateDifference,
|
||||||
formatHumanReadableDuration,
|
formatHumanReadableDuration,
|
||||||
|
formatReportingDuration,
|
||||||
parseTimeInput,
|
parseTimeInput,
|
||||||
} from '@/packages/ui/src/utils/time';
|
} from '@/packages/ui/src/utils/time';
|
||||||
import { computed, ref, inject, type ComputedRef } from 'vue';
|
import { computed, ref, inject, type ComputedRef } from 'vue';
|
||||||
@@ -18,6 +19,7 @@ const organizationSettings = computed(() => ({
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
start: string;
|
start: string;
|
||||||
end: string | null;
|
end: string | null;
|
||||||
|
isReport?: boolean;
|
||||||
}>();
|
}>();
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
changed: [start: string, end: string | null];
|
changed: [start: string, end: string | null];
|
||||||
@@ -51,7 +53,8 @@ const currentTime = computed({
|
|||||||
if (temporaryCustomTimerEntry.value !== '') {
|
if (temporaryCustomTimerEntry.value !== '') {
|
||||||
return temporaryCustomTimerEntry.value;
|
return temporaryCustomTimerEntry.value;
|
||||||
}
|
}
|
||||||
return formatHumanReadableDuration(
|
const formatter = props.isReport ? formatReportingDuration : formatHumanReadableDuration;
|
||||||
|
return formatter(
|
||||||
calculateDifference(props.start, props.end),
|
calculateDifference(props.start, props.end),
|
||||||
organizationSettings.value.intervalFormat,
|
organizationSettings.value.intervalFormat,
|
||||||
organizationSettings.value.numberFormat
|
organizationSettings.value.numberFormat
|
||||||
|
|||||||
@@ -47,14 +47,14 @@ function selectUnselectAll(value: boolean) {
|
|||||||
class="group-hover:block hidden"
|
class="group-hover:block hidden"
|
||||||
@update:checked="selectUnselectAll"></Checkbox>
|
@update:checked="selectUnselectAll"></Checkbox>
|
||||||
</div>
|
</div>
|
||||||
<span class="font-medium text-text-secondary">
|
<span class="text-text-primary">
|
||||||
{{ formatWeekday(date) }}
|
{{ formatWeekday(date) }}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-text-tertiary ml-2">
|
<span class="text-text-secondary ml-2">
|
||||||
{{ formatDate(date, organization?.date_format) }}
|
{{ formatDate(date, organization?.date_format) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-text-secondary pr-2 @lg:pr-[92px]">
|
<div class="text-text-primary pr-2 @lg:pr-[92px]">
|
||||||
<span class="font-medium">
|
<span class="font-medium">
|
||||||
{{
|
{{
|
||||||
formatHumanReadableDuration(
|
formatHumanReadableDuration(
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ useSelectEvents(
|
|||||||
v-model="tempDescription"
|
v-model="tempDescription"
|
||||||
placeholder="What are you working on?"
|
placeholder="What are you working on?"
|
||||||
data-testid="time_entry_description"
|
data-testid="time_entry_description"
|
||||||
class="w-full rounded-l-lg py-4 sm:py-2.5 px-3.5 border-b border-b-card-background-separator @2xl:px-4 text-base text-text-primary bg-transparent border-none placeholder-text-secondary font-medium focus:ring-0 transition"
|
class="w-full rounded-l-lg py-4 sm:py-2.5 px-3.5 border-b border-b-card-background-separator @2xl:px-4 text-base text-text-primary bg-transparent border-none placeholder-text-secondary focus:ring-0 transition"
|
||||||
type="text"
|
type="text"
|
||||||
@keydown.enter="startTimerIfNotActive"
|
@keydown.enter="startTimerIfNotActive"
|
||||||
@keydown.esc="showDropdown = false"
|
@keydown.esc="showDropdown = false"
|
||||||
|
|||||||
@@ -664,7 +664,8 @@ const showCreateProject = ref(false);
|
|||||||
:organization-billable-rate="organizationBillableRate"
|
:organization-billable-rate="organizationBillableRate"
|
||||||
:currency="currency"
|
:currency="currency"
|
||||||
:clients="clients"
|
:clients="clients"
|
||||||
:create-project></ProjectCreateModal>
|
:create-project
|
||||||
|
:initial-project-name="searchValue"></ProjectCreateModal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
|||||||
v-bind="{ ...forwarded, ...$attrs }"
|
v-bind="{ ...forwarded, ...$attrs }"
|
||||||
:class="
|
:class="
|
||||||
cn(
|
cn(
|
||||||
'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
'z-50 overflow-hidden rounded-md shadow-dropdown border border-border bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
props.class
|
props.class
|
||||||
)
|
)
|
||||||
">
|
">
|
||||||
|
|||||||
@@ -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 {
|
export function formatDuration(duration: number): string {
|
||||||
const dayJsDuration = dayjs.duration(duration, 's');
|
const dayJsDuration = dayjs.duration(duration, 's');
|
||||||
const hours = Math.floor(dayJsDuration.asHours());
|
const hours = Math.floor(dayJsDuration.asHours());
|
||||||
@@ -147,6 +167,23 @@ export function getLocalizedDayJs(timestamp?: string | null) {
|
|||||||
return dayjs.utc(timestamp).tz(getUserTimezone());
|
return dayjs.utc(timestamp).tz(getUserTimezone());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a dayjs instance for a specific wall-clock time on a given day.
|
||||||
|
* Sets hour/minute directly to avoid DST issues with .add(minutes) on
|
||||||
|
* transition days. Negative or overflow values are normalised by shifting
|
||||||
|
* whole days (`.add(n, 'day')` is DST-safe).
|
||||||
|
*/
|
||||||
|
export function getLocalizedDayJsFromMinutes(dayStr: string, minutesFromMidnight: number) {
|
||||||
|
const dayOffset = Math.floor(minutesFromMidnight / (24 * 60));
|
||||||
|
const remainder = minutesFromMidnight - dayOffset * 24 * 60;
|
||||||
|
return dayjs
|
||||||
|
.tz(`${dayStr}T00:00:00`, getUserTimezone())
|
||||||
|
.add(dayOffset, 'day')
|
||||||
|
.hour(Math.floor(remainder / 60))
|
||||||
|
.minute(Math.round(remainder % 60))
|
||||||
|
.second(0);
|
||||||
|
}
|
||||||
|
|
||||||
export function getLocalizedDateFromTimestamp(timestamp: string) {
|
export function getLocalizedDateFromTimestamp(timestamp: string) {
|
||||||
return getLocalizedDayJs(timestamp).format('YYYY-MM-DD');
|
return getLocalizedDayJs(timestamp).format('YYYY-MM-DD');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,28 +14,30 @@
|
|||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
:root.dark {
|
:root.dark {
|
||||||
--color-bg-primary: oklch(0.14 0.0041 285.97);
|
/* Linear/Raycast: cool blue-tinted neutrals, content surface at Material floor */
|
||||||
--color-bg-secondary: oklch(0.18 0.005 285.97);
|
--color-bg-primary: oklch(0.17 0 0); /* content surface */
|
||||||
--color-bg-tertiary: oklch(0.22 0.0112 285.97);
|
--color-bg-secondary: oklch(0.20 0 0); /* cards, input fills */
|
||||||
--color-bg-quaternary: oklch(0.26 0.015 285.97);
|
--color-bg-tertiary: oklch(0.25 0 0); /* hover / active row */
|
||||||
--color-bg-background: oklch(0.1 0 0);
|
--color-bg-quaternary: oklch(0.29 0 0); /* pressed / selected */
|
||||||
--color-text-primary: #ffffff;
|
--color-bg-elevated: oklch(0.22 0 0); /* popovers, modals, dropdowns */
|
||||||
--color-text-secondary: #e3e4e6;
|
--color-bg-background: oklch(0.14 0 0); /* sidebar / chrome — recessed */
|
||||||
--color-text-tertiary: #969799;
|
--color-text-primary: oklch(0.97 0 0);
|
||||||
--color-text-quaternary: #595a5c;
|
--color-text-secondary: oklch(0.85 0 0);
|
||||||
|
--color-text-tertiary: oklch(0.70 0 0);
|
||||||
|
--color-text-quaternary: oklch(0.55 0 0);
|
||||||
|
|
||||||
--color-border-primary: #191b1f;
|
--color-border-primary: oklch(0.24 0 0); /* separators — above elevated */
|
||||||
--color-border-secondary: oklch(0.25 0.0098 268.31);
|
--color-border-secondary: oklch(0.28 0 0); /* card borders */
|
||||||
--color-border-tertiary: #2c2e33;
|
--color-border-tertiary: oklch(0.32 0 0); /* input borders */
|
||||||
--color-border-quaternary: #393b42;
|
--color-border-quaternary: oklch(0.36 0 0); /* emphasized borders */
|
||||||
--color-input-border-active: rgba(255, 255, 255, 0.15);
|
--color-input-border-active: rgba(255, 255, 255, 0.18);
|
||||||
|
|
||||||
--theme-color-chart: var(--color-accent-200);
|
--theme-color-chart: var(--color-accent-200);
|
||||||
|
|
||||||
--theme-color-menu-active: var(--color-bg-secondary);
|
--theme-color-menu-active: var(--color-bg-secondary);
|
||||||
--theme-color-card-background: var(--color-bg-secondary);
|
--theme-color-card-background: var(--color-bg-secondary);
|
||||||
--theme-shadow-card: 0 4px 7px 0px rgb(0 0 0 / 15%);
|
--theme-shadow-card: 0 4px 7px 0px rgb(0 0 0 / 15%);
|
||||||
--theme-shadow-dropdown: 0 4px 7px 0px rgb(0 0 0 / 40%);
|
--theme-shadow-dropdown: 0 8px 24px -4px rgb(0 0 0 / 55%), 0 2px 6px 0 rgb(0 0 0 / 35%);
|
||||||
|
|
||||||
--theme-color-card-background-active: var(--color-bg-tertiary);
|
--theme-color-card-background-active: var(--color-bg-tertiary);
|
||||||
|
|
||||||
@@ -187,7 +189,7 @@ body {
|
|||||||
--foreground: var(--color-text-primary);
|
--foreground: var(--color-text-primary);
|
||||||
--card: var(--theme-color-card-background);
|
--card: var(--theme-color-card-background);
|
||||||
--card-foreground: var(--color-text-primary);
|
--card-foreground: var(--color-text-primary);
|
||||||
--popover: var(--color-bg-secondary);
|
--popover: var(--color-bg-elevated, var(--color-bg-secondary));
|
||||||
--popover-foreground: var(--color-text-primary);
|
--popover-foreground: var(--color-text-primary);
|
||||||
--primary: var(--color-bg-primary);
|
--primary: var(--color-bg-primary);
|
||||||
--primary-foreground: var(--color-text-primary);
|
--primary-foreground: var(--color-text-primary);
|
||||||
|
|||||||
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="padding: 8px 12px; border-radius: 8px;">
|
||||||
<div style="color: #71717a; font-weight: 600;">Duration</div>
|
<div style="color: #71717a; font-weight: 600;">Duration</div>
|
||||||
<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>
|
</div>
|
||||||
@if($showBillableRate)
|
@if($showBillableRate)
|
||||||
<div style="padding: 8px 12px; border-radius: 8px;">
|
<div style="padding: 8px 12px; border-radius: 8px;">
|
||||||
@@ -199,7 +199,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td style="text-align: left;">
|
<td style="text-align: left;">
|
||||||
{{ $localization->formatInterval(CarbonInterval::seconds($group1Entry['seconds'])) }}
|
{{ $localization->formatIntervalForReporting(CarbonInterval::seconds($group1Entry['seconds'])) }}
|
||||||
</td>
|
</td>
|
||||||
@if($showBillableRate)
|
@if($showBillableRate)
|
||||||
<td style="text-align: right;">
|
<td style="text-align: right;">
|
||||||
@@ -214,7 +214,7 @@
|
|||||||
Total
|
Total
|
||||||
</td>
|
</td>
|
||||||
<td style="font-weight: 500;color: #18181b;">
|
<td style="font-weight: 500;color: #18181b;">
|
||||||
{{ $localization->formatInterval(CarbonInterval::seconds($aggregatedData['seconds'])) }}
|
{{ $localization->formatIntervalForReporting(CarbonInterval::seconds($aggregatedData['seconds'])) }}
|
||||||
</td>
|
</td>
|
||||||
@if($showBillableRate)
|
@if($showBillableRate)
|
||||||
<td style="text-align: right; font-weight: 500;color: #18181b;">
|
<td style="text-align: right; font-weight: 500;color: #18181b;">
|
||||||
@@ -282,7 +282,7 @@
|
|||||||
@endif
|
@endif
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{{ $localization->formatInterval($duration) }}
|
{{ $localization->formatIntervalForReporting($duration) }}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{{ $localization->formatNumber($duration->totalHours) }}
|
{{ $localization->formatNumber($duration->totalHours) }}
|
||||||
@@ -403,7 +403,7 @@
|
|||||||
type: "bar",
|
type: "bar",
|
||||||
data: {!! json_encode(collect($dataHistoryChart['grouped_data'])->map(fn($value) => (object) [
|
data: {!! json_encode(collect($dataHistoryChart['grouped_data'])->map(fn($value) => (object) [
|
||||||
'value' => $value['seconds'],
|
'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()) !!},
|
])->toArray()) !!},
|
||||||
itemStyle: {
|
itemStyle: {
|
||||||
borderColor: "#7dd3fc",
|
borderColor: "#7dd3fc",
|
||||||
|
|||||||
@@ -138,7 +138,7 @@
|
|||||||
<div style="padding: 8px 12px; border-radius: 8px;">
|
<div style="padding: 8px 12px; border-radius: 8px;">
|
||||||
<div style="color: #71717a; font-weight: 600;">Duration</div>
|
<div style="color: #71717a; font-weight: 600;">Duration</div>
|
||||||
<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>
|
</div>
|
||||||
@if($showBillableRate)
|
@if($showBillableRate)
|
||||||
<div style="padding: 8px 12px; border-radius: 8px;">
|
<div style="padding: 8px 12px; border-radius: 8px;">
|
||||||
@@ -189,7 +189,7 @@
|
|||||||
{{ $localization->formatTime($timeEntry->start->timezone($timezone)) }} - {{ $localization->formatTime($timeEntry->end->timezone($timezone)) }}
|
{{ $localization->formatTime($timeEntry->start->timezone($timezone)) }} - {{ $localization->formatTime($timeEntry->end->timezone($timezone)) }}
|
||||||
</td>
|
</td>
|
||||||
<td style="overflow-wrap: break-word; min-width: 75px;">
|
<td style="overflow-wrap: break-word; min-width: 75px;">
|
||||||
{{ $localization->formatInterval($timeEntry->getDuration()) }}
|
{{ $localization->formatIntervalForReporting($timeEntry->getDuration()) }}
|
||||||
</td>
|
</td>
|
||||||
<td style="overflow-wrap: break-word;">{{ $timeEntry->billable ? 'Yes' : 'No' }}</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>
|
<td style="overflow-wrap: break-word; min-width: 75px;">{{ count($timeEntry->tagsRelation) === 0 ? '-' : $timeEntry->tagsRelation->implode('name', ', ') }}</td>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ use App\Models\TimeEntry;
|
|||||||
use App\Service\CurrencyService;
|
use App\Service\CurrencyService;
|
||||||
use App\Service\Dto\ReportPropertiesDto;
|
use App\Service\Dto\ReportPropertiesDto;
|
||||||
use App\Service\TimeEntryFilter;
|
use App\Service\TimeEntryFilter;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Tests\Unit\Endpoint\Api\V1\ApiEndpointTestAbstract;
|
use Tests\Unit\Endpoint\Api\V1\ApiEndpointTestAbstract;
|
||||||
|
|
||||||
@@ -82,10 +83,11 @@ class PublicReportEndpointTest extends ApiEndpointTestAbstract
|
|||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
$timezone = 'Europe/Vienna';
|
$timezone = 'Europe/Vienna';
|
||||||
|
$now = Carbon::now($timezone);
|
||||||
$reportDto = new ReportPropertiesDto;
|
$reportDto = new ReportPropertiesDto;
|
||||||
$organization = Organization::factory()->create();
|
$organization = Organization::factory()->create();
|
||||||
$reportDto->start = now()->subDays(2);
|
$reportDto->start = $now->copy()->subDays(2);
|
||||||
$reportDto->end = now();
|
$reportDto->end = $now->copy();
|
||||||
$reportDto->group = TimeEntryAggregationType::Project;
|
$reportDto->group = TimeEntryAggregationType::Project;
|
||||||
$reportDto->subGroup = TimeEntryAggregationType::Task;
|
$reportDto->subGroup = TimeEntryAggregationType::Task;
|
||||||
$reportDto->historyGroup = TimeEntryAggregationTypeInterval::Day;
|
$reportDto->historyGroup = TimeEntryAggregationTypeInterval::Day;
|
||||||
@@ -102,9 +104,9 @@ class PublicReportEndpointTest extends ApiEndpointTestAbstract
|
|||||||
$task2 = Task::factory()->forOrganization($organization)->forProject($project)->create([
|
$task2 = Task::factory()->forOrganization($organization)->forProject($project)->create([
|
||||||
'id' => '3c54796d-5ab4-41e1-8f30-aa61a0a919ae',
|
'id' => '3c54796d-5ab4-41e1-8f30-aa61a0a919ae',
|
||||||
]);
|
]);
|
||||||
TimeEntry::factory()->forOrganization($organization)->forTask($task1)->startWithDuration(now()->subDay(), 100)->create();
|
TimeEntry::factory()->forOrganization($organization)->forTask($task1)->startWithDuration($now->copy()->subDay(), 100)->create();
|
||||||
TimeEntry::factory()->forOrganization($organization)->forTask($task2)->startWithDuration(now()->subDay(), 100)->create();
|
TimeEntry::factory()->forOrganization($organization)->forTask($task2)->startWithDuration($now->copy()->subDay(), 100)->create();
|
||||||
TimeEntry::factory()->forOrganization($organization)->startWithDuration(now()->subDay(), 100)->create();
|
TimeEntry::factory()->forOrganization($organization)->startWithDuration($now->copy()->subDay(), 100)->create();
|
||||||
|
|
||||||
$currencyService = app(CurrencyService::class);
|
$currencyService = app(CurrencyService::class);
|
||||||
|
|
||||||
@@ -193,7 +195,7 @@ class PublicReportEndpointTest extends ApiEndpointTestAbstract
|
|||||||
'grouped_type' => TimeEntryAggregationTypeInterval::Day->value,
|
'grouped_type' => TimeEntryAggregationTypeInterval::Day->value,
|
||||||
'grouped_data' => [
|
'grouped_data' => [
|
||||||
[
|
[
|
||||||
'key' => now()->timezone($timezone)->subDays(2)->toDateString(),
|
'key' => $now->copy()->subDays(2)->toDateString(),
|
||||||
'seconds' => 0,
|
'seconds' => 0,
|
||||||
'cost' => 0,
|
'cost' => 0,
|
||||||
'grouped_type' => null,
|
'grouped_type' => null,
|
||||||
@@ -202,7 +204,7 @@ class PublicReportEndpointTest extends ApiEndpointTestAbstract
|
|||||||
'color' => null,
|
'color' => null,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'key' => now()->timezone($timezone)->subDays(1)->toDateString(),
|
'key' => $now->copy()->subDays(1)->toDateString(),
|
||||||
'seconds' => 300,
|
'seconds' => 300,
|
||||||
'cost' => 0,
|
'cost' => 0,
|
||||||
'grouped_type' => null,
|
'grouped_type' => null,
|
||||||
@@ -211,7 +213,7 @@ class PublicReportEndpointTest extends ApiEndpointTestAbstract
|
|||||||
'color' => null,
|
'color' => null,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'key' => now()->timezone($timezone)->toDateString(),
|
'key' => $now->toDateString(),
|
||||||
'seconds' => 0,
|
'seconds' => 0,
|
||||||
'cost' => 0,
|
'cost' => 0,
|
||||||
'grouped_type' => null,
|
'grouped_type' => null,
|
||||||
@@ -332,6 +334,7 @@ class PublicReportEndpointTest extends ApiEndpointTestAbstract
|
|||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
$timezone = 'Europe/Vienna';
|
$timezone = 'Europe/Vienna';
|
||||||
|
$now = Carbon::now($timezone);
|
||||||
$organization = Organization::factory()->create();
|
$organization = Organization::factory()->create();
|
||||||
$client = Client::factory()->forOrganization($organization)->create();
|
$client = Client::factory()->forOrganization($organization)->create();
|
||||||
$project = Project::factory()->forClient($client)->forOrganization($organization)->create();
|
$project = Project::factory()->forClient($client)->forOrganization($organization)->create();
|
||||||
@@ -341,14 +344,14 @@ class PublicReportEndpointTest extends ApiEndpointTestAbstract
|
|||||||
TimeEntry::factory()->forOrganization($organization)
|
TimeEntry::factory()->forOrganization($organization)
|
||||||
->forTask($task)
|
->forTask($task)
|
||||||
->billable()
|
->billable()
|
||||||
->startWithDuration(now()->subDay(), 100)
|
->startWithDuration($now->copy()->subDay(), 100)
|
||||||
->create([
|
->create([
|
||||||
'tags' => [$tag->getKey()],
|
'tags' => [$tag->getKey()],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$reportDto = new ReportPropertiesDto;
|
$reportDto = new ReportPropertiesDto;
|
||||||
$reportDto->start = now()->subDays(2);
|
$reportDto->start = $now->copy()->subDays(2);
|
||||||
$reportDto->end = now();
|
$reportDto->end = $now->copy();
|
||||||
$reportDto->group = TimeEntryAggregationType::Project;
|
$reportDto->group = TimeEntryAggregationType::Project;
|
||||||
$reportDto->subGroup = TimeEntryAggregationType::Task;
|
$reportDto->subGroup = TimeEntryAggregationType::Task;
|
||||||
$reportDto->historyGroup = TimeEntryAggregationTypeInterval::Day;
|
$reportDto->historyGroup = TimeEntryAggregationTypeInterval::Day;
|
||||||
@@ -394,7 +397,7 @@ class PublicReportEndpointTest extends ApiEndpointTestAbstract
|
|||||||
'grouped_type' => TimeEntryAggregationTypeInterval::Day->value,
|
'grouped_type' => TimeEntryAggregationTypeInterval::Day->value,
|
||||||
'grouped_data' => [
|
'grouped_data' => [
|
||||||
[
|
[
|
||||||
'key' => now()->timezone($timezone)->subDays(2)->toDateString(),
|
'key' => $now->copy()->subDays(2)->toDateString(),
|
||||||
'seconds' => 0,
|
'seconds' => 0,
|
||||||
'cost' => 0,
|
'cost' => 0,
|
||||||
'grouped_type' => null,
|
'grouped_type' => null,
|
||||||
@@ -403,7 +406,7 @@ class PublicReportEndpointTest extends ApiEndpointTestAbstract
|
|||||||
'color' => null,
|
'color' => null,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'key' => now()->timezone($timezone)->subDays(1)->toDateString(),
|
'key' => $now->copy()->subDays(1)->toDateString(),
|
||||||
'seconds' => 0,
|
'seconds' => 0,
|
||||||
'cost' => 0,
|
'cost' => 0,
|
||||||
'grouped_type' => null,
|
'grouped_type' => null,
|
||||||
@@ -412,7 +415,7 @@ class PublicReportEndpointTest extends ApiEndpointTestAbstract
|
|||||||
'color' => null,
|
'color' => null,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'key' => now()->timezone($timezone)->toDateString(),
|
'key' => $now->toDateString(),
|
||||||
'seconds' => 0,
|
'seconds' => 0,
|
||||||
'cost' => 0,
|
'cost' => 0,
|
||||||
'grouped_type' => null,
|
'grouped_type' => null,
|
||||||
|
|||||||
@@ -2490,6 +2490,47 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
$response->assertForbidden();
|
$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
|
public function test_update_endpoint_fails_if_user_has_no_permission_to_update_time_entries_for_other_users_in_organization(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|||||||
@@ -129,6 +129,58 @@ class LocalizationServiceTest extends TestCaseWithDatabase
|
|||||||
$this->assertSame('30001:03:04', $formatted);
|
$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
|
public function test_format_currency_with_type_symbol_after_with_space_and_number_format_thousands_space_decimal_comma(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|||||||
Reference in New Issue
Block a user