Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot]
048b2e0204 Bump the major-updates group across 1 directory with 12 updates
Bumps the major-updates group with 12 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@tanstack/vue-query-devtools](https://github.com/TanStack/query/tree/HEAD/packages/vue-query-devtools) | `5.91.0` | `6.1.10` |
| [lucide-vue-next](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-vue-next) | `0.487.0` | `1.0.0` |
| [tailwind-merge](https://github.com/dcastil/tailwind-merge) | `2.6.1` | `3.5.0` |
| [@eslint/js](https://github.com/eslint/eslint/tree/HEAD/packages/js) | `9.39.2` | `10.0.1` |
| [@inertiajs/vue3](https://github.com/inertiajs/inertia/tree/HEAD/packages/vue3) | `2.3.13` | `3.0.0` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `22.19.7` | `25.5.0` |
| [laravel-vite-plugin](https://github.com/laravel/vite-plugin) | `2.1.0` | `3.0.0` |
| [postcss-import](https://github.com/postcss/postcss-import) | `15.1.0` | `16.1.1` |
| [postcss-nesting](https://github.com/csstools/postcss-plugins/tree/HEAD/plugins/postcss-nesting) | `12.1.5` | `14.0.0` |
| [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `3.4.19` | `4.2.2` |
| [typescript](https://github.com/microsoft/TypeScript) | `5.9.3` | `6.0.2` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `7.3.1` | `8.0.3` |



Updates `@tanstack/vue-query-devtools` from 5.91.0 to 6.1.10
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/vue-query-devtools/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/vue-query-devtools@6.1.10/packages/vue-query-devtools)

Updates `lucide-vue-next` from 0.487.0 to 1.0.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.0.0/packages/lucide-vue-next)

Updates `tailwind-merge` from 2.6.1 to 3.5.0
- [Release notes](https://github.com/dcastil/tailwind-merge/releases)
- [Commits](https://github.com/dcastil/tailwind-merge/compare/v2.6.1...v3.5.0)

Updates `@eslint/js` from 9.39.2 to 10.0.1
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/commits/v10.0.1/packages/js)

Updates `@inertiajs/vue3` from 2.3.13 to 3.0.0
- [Release notes](https://github.com/inertiajs/inertia/releases)
- [Changelog](https://github.com/inertiajs/inertia/blob/3.x/CHANGELOG.md)
- [Commits](https://github.com/inertiajs/inertia/commits/v3.0.0/packages/vue3)

Updates `@types/node` from 22.19.7 to 25.5.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `laravel-vite-plugin` from 2.1.0 to 3.0.0
- [Release notes](https://github.com/laravel/vite-plugin/releases)
- [Changelog](https://github.com/laravel/vite-plugin/blob/3.x/CHANGELOG.md)
- [Upgrade guide](https://github.com/laravel/vite-plugin/blob/3.x/UPGRADE.md)
- [Commits](https://github.com/laravel/vite-plugin/compare/v2.1.0...v3.0.0)

Updates `postcss-import` from 15.1.0 to 16.1.1
- [Release notes](https://github.com/postcss/postcss-import/releases)
- [Changelog](https://github.com/postcss/postcss-import/blob/master/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss-import/compare/15.1.0...16.1.1)

Updates `postcss-nesting` from 12.1.5 to 14.0.0
- [Changelog](https://github.com/csstools/postcss-plugins/blob/main/plugins/postcss-nesting/CHANGELOG.md)
- [Commits](https://github.com/csstools/postcss-plugins/commits/HEAD/plugins/postcss-nesting)

Updates `tailwindcss` from 3.4.19 to 4.2.2
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.2.2/packages/tailwindcss)

Updates `typescript` from 5.9.3 to 6.0.2
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.9.3...v6.0.2)

Updates `vite` from 7.3.1 to 8.0.3
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/create-vite@8.0.3/packages/vite)

---
updated-dependencies:
- dependency-name: "@tanstack/vue-query-devtools"
  dependency-version: 6.1.10
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: lucide-vue-next
  dependency-version: 1.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: tailwind-merge
  dependency-version: 3.5.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: "@eslint/js"
  dependency-version: 10.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: "@inertiajs/vue3"
  dependency-version: 3.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: "@types/node"
  dependency-version: 25.5.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: laravel-vite-plugin
  dependency-version: 3.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: postcss-import
  dependency-version: 16.1.1
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: postcss-nesting
  dependency-version: 14.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: tailwindcss
  dependency-version: 4.2.2
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: typescript
  dependency-version: 6.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: vite
  dependency-version: 8.0.3
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-13 08:31:23 +00:00
58 changed files with 1058 additions and 2504 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -68,7 +68,6 @@ RUN apt-get update; \
wget \
vim \
git \
gosu \
ncdu \
procps \
unzip \
@@ -194,20 +193,9 @@ COPY --link --chown=${WWWUSER}:${WWWUSER} . .
#COPY --link --chown=${WWWUSER}:${WWWUSER} --from=build ${ROOT}/public public
RUN mkdir -p \
storage/framework/{sessions,views,cache/data,testing} \
storage/framework/{sessions,views,cache,testing} \
storage/logs \
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
bootstrap/cache && chmod -R a+rw storage
#RUN composer install \
# --classmap-authoritative \

View File

@@ -1,6 +1,7 @@
[program:octane]
process_name = %(program_name)s_%(process_num)s
command = php %(ENV_ROOT)s/artisan octane:frankenphp --host=0.0.0.0 --port=8000 --admin-port=2019 --caddyfile=%(ENV_ROOT)s/docker/prod/deployment/octane/FrankenPHP/Caddyfile
user = %(ENV_USER)s
priority = 1
autostart = true
autorestart = true
@@ -13,6 +14,7 @@ stderr_logfile_maxbytes = 0
[program:horizon]
process_name = %(program_name)s_%(process_num)s
command = php %(ENV_ROOT)s/artisan horizon
user = %(ENV_USER)s
priority = 3
autostart = %(ENV_WITH_HORIZON)s
autorestart = true
@@ -25,6 +27,7 @@ stopwaitsecs = 3600
[program:scheduler]
process_name = %(program_name)s_%(process_num)s
command = supercronic -overlapping /etc/supercronic/laravel
user = %(ENV_USER)s
autostart = %(ENV_WITH_SCHEDULER)s
autorestart = true
stdout_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
@@ -35,6 +38,7 @@ stderr_logfile_maxbytes = 200MB
[program:clear-scheduler-cache]
process_name = %(program_name)s_%(process_num)s
command = php %(ENV_ROOT)s/artisan schedule:clear-cache
user = %(ENV_USER)s
autostart = %(ENV_WITH_SCHEDULER)s
autorestart = false
startsecs = 0
@@ -47,6 +51,7 @@ stderr_logfile_maxbytes = 200MB
[program:reverb]
process_name = %(program_name)s_%(process_num)s
command = php %(ENV_ROOT)s/artisan reverb:start
user = %(ENV_USER)s
priority = 2
autostart = %(ENV_WITH_REVERB)s
autorestart = true

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
[program:scheduler]
process_name = %(program_name)s_%(process_num)s
command = supercronic -overlapping /etc/supercronic/laravel
user = %(ENV_USER)s
autostart = true
autorestart = true
stdout_logfile = /dev/stdout
@@ -11,6 +12,7 @@ stderr_logfile_maxbytes = 0
[program:clear-scheduler-cache]
process_name = %(program_name)s_%(process_num)s
command = php %(ENV_ROOT)s/artisan schedule:clear-cache
user = %(ENV_USER)s
autostart = true
autorestart = false
startsecs = 0

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

2473
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -19,26 +19,26 @@
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.19.0",
"@inertiajs/vue3": "^2.0.0",
"@eslint/js": "^10.0.1",
"@inertiajs/vue3": "^3.0.3",
"@playwright/test": "^1.41.1",
"@tailwindcss/forms": "^0.5.9",
"@tailwindcss/typography": "^0.5.15",
"@types/chroma-js": "^3.1.0",
"@types/node": "^22.10.10",
"@types/node": "^25.6.0",
"@vitejs/plugin-vue": "^6.0.3",
"@vue/tsconfig": "^0.8.0",
"autoprefixer": "^10.4.20",
"axios": "^1.6.4",
"eslint-plugin-unused-imports": "^4.1.4",
"laravel-vite-plugin": "^2.1.0",
"laravel-vite-plugin": "^3.0.1",
"openapi-zod-client": "^1.16.2",
"postcss": "^8.4.47",
"postcss-import": "^15.1.0",
"postcss-nesting": "^12.1.5",
"tailwindcss": "^3.4.13",
"typescript": "^5.7.3",
"vite": "^7.0.0",
"postcss-import": "^16.1.1",
"postcss-nesting": "^14.0.0",
"tailwindcss": "^4.2.2",
"typescript": "^6.0.2",
"vite": "^8.0.8",
"vite-plugin-checker": "^0.12.0",
"vue": "^3.5.0",
"vue-tsc": "^3.0.0"
@@ -51,7 +51,7 @@
"@tailwindcss/container-queries": "^0.1.1",
"@tanstack/vue-form": "^1.3.1",
"@tanstack/vue-query": "^5.56.2",
"@tanstack/vue-query-devtools": "^5.58.0",
"@tanstack/vue-query-devtools": "^6.1.16",
"@tanstack/vue-table": "^8.21.2",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.3.0",
@@ -64,12 +64,12 @@
"dayjs": "^1.11.11",
"echarts": "^6.0.0",
"focus-trap": "^8.0.0",
"lucide-vue-next": "^0.487.0",
"lucide-vue-next": "^1.0.0",
"parse-duration": "^2.0.1",
"pinia": "^3.0.0",
"radix-vue": "^1.9.6",
"reka-ui": "^2.8.2",
"tailwind-merge": "^2.6.0",
"tailwind-merge": "^3.5.0",
"tailwindcss-animate": "^1.0.7",
"vue-echarts": "^8.0.0",
"zod": "^3.23.8"

View File

@@ -57,11 +57,11 @@ const showEditModal = ref(false);
</span>
</div>
<div
class="whitespace-nowrap flex items-center px-3 py-4 text-sm text-text-primary">
<span> {{ projectCount }} Projects </span>
class="whitespace-nowrap flex items-center px-3 py-4 text-sm font-medium text-text-primary">
<span class="text-text-secondary"> {{ projectCount }} Projects </span>
</div>
<div
class="whitespace-nowrap px-3 py-4 text-sm text-text-primary flex space-x-1.5 items-center">
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1.5 items-center font-medium">
<template v-if="client.is_archived">
<ArchiveBoxIcon class="w-4 text-icon-default"></ArchiveBoxIcon>
<span>Archived</span>

View File

@@ -83,28 +83,27 @@ const userHasValidMailAddress = computed(() => {
{{ member.name }}
</span>
</div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-primary">
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
{{ member.email }}
</div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-primary">
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
{{ capitalizeFirstLetter(member.role) }}
</div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-primary">
<span v-if="member.billable_rate">
{{
formatCents(
member.billable_rate,
organization?.currency,
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
)
}}
</span>
<span v-else class="text-text-tertiary"> -- </span>
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
{{
member.billable_rate
? formatCents(
member.billable_rate,
organization?.currency,
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
)
: '--'
}}
</div>
<div
class="whitespace-nowrap px-3 py-4 text-sm text-text-primary flex space-x-1.5 items-center">
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1.5 items-center font-medium">
<template v-if="member.is_placeholder === false">
<CheckCircleIcon class="w-4 text-icon-default"></CheckCircleIcon>
<span>Active</span>

View File

@@ -72,7 +72,7 @@ const billableRateInfo = computed(() => {
return 'Default Rate';
}
}
return null;
return '--';
});
const showEditProjectModal = ref(false);
@@ -98,13 +98,13 @@ const showEditProjectModal = ref(false);
</span>
<span class="text-text-secondary"> {{ projectTasksCount }} Tasks </span>
</div>
<div class="whitespace-nowrap min-w-0 px-3 py-4 text-sm text-text-primary">
<div class="whitespace-nowrap min-w-0 px-3 py-4 text-sm text-text-secondary">
<div v-if="project.client_id" class="overflow-ellipsis overflow-hidden">
{{ client?.name }}
</div>
<div v-else class="text-text-tertiary">No client</div>
<div v-else>No client</div>
</div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-primary">
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
<div v-if="project.spent_time">
{{
formatHumanReadableDuration(
@@ -114,24 +114,23 @@ const showEditProjectModal = ref(false);
)
}}
</div>
<div v-else class="text-text-tertiary">--</div>
<div v-else>--</div>
</div>
<div class="whitespace-nowrap px-3 flex items-center text-sm text-text-primary">
<div class="whitespace-nowrap px-3 flex items-center text-sm text-text-secondary">
<UpgradeBadge v-if="!isAllowedToPerformPremiumAction()"></UpgradeBadge>
<EstimatedTimeProgress
v-else-if="project.estimated_time"
:estimated="project.estimated_time"
:current="project.spent_time"></EstimatedTimeProgress>
<span v-else class="text-text-tertiary"> -- </span>
<span v-else> -- </span>
</div>
<div
v-if="showBillableRate"
class="whitespace-nowrap px-3 py-4 text-sm text-text-primary">
<span v-if="billableRateInfo">{{ billableRateInfo }}</span>
<span v-else class="text-text-tertiary">--</span>
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
{{ billableRateInfo }}
</div>
<div
class="whitespace-nowrap px-3 py-4 text-sm text-text-primary flex space-x-1.5 items-center font-medium">
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1.5 items-center font-medium">
<template v-if="project.is_archived">
<ArchiveBoxIcon class="w-4 text-icon-default"></ArchiveBoxIcon>
<span>Archived</span>

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,8 +7,8 @@ defineProps<{
<template>
<div class="rounded-lg bg-card-background border-card-border shadow-card border px-3.5 py-2.5">
<dt class="font-medium text-sm text-text-secondary">{{ title }}</dt>
<dd class="text-xl text-text-primary pt-1 font-medium">
<dt class="font-semibold text-sm text-text-secondary">{{ title }}</dt>
<dd class="text-xl text-text-primary pt-1 font-semibold">
{{ value ?? '--' }}
</dd>
</div>

View File

@@ -23,7 +23,7 @@ defineProps<{
<div class="items-center justify-center flex-1 hidden @2xs:flex">
<DayOverviewCardChart :history="history"></DayOverviewCardChart>
</div>
<div class="flex text-sm items-center justify-center text-text-primary min-w-[65px]">
<div class="flex text-sm items-center justify-center text-text-secondary min-w-[65px]">
{{
formatHumanReadableDuration(
duration,

View File

@@ -47,9 +47,9 @@ async function startTaskTimer() {
<template>
<div class="px-3.5 py-2 grid grid-cols-5">
<div class="col-span-4">
<p class="text-text-primary text-sm pb-1.5 truncate">
<p class="text-text-secondary text-sm pb-1.5 truncate">
<span v-if="timeEntry.description"> {{ timeEntry.description }}</span>
<span v-else class="text-text-secondary">No description</span>
<span v-else>No description</span>
</p>
<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">

View File

@@ -48,7 +48,7 @@ const { data: latestTeamActivity, isLoading } = useQuery({
class="text-center flex flex-1 justify-center items-center">
<div>
<UserGroupIcon class="w-8 text-icon-default inline pb-2"></UserGroupIcon>
<h3 class="text-text-primary font-medium text-sm">Invite your co-workers</h3>
<h3 class="text-text-primary font-semibold text-sm">Invite your co-workers</h3>
<p class="pb-5 text-sm">You can invite your entire team.</p>
<SecondaryButton @click="router.visit(route('members'))"
>Go to Members

View File

@@ -11,7 +11,7 @@ defineProps<{
<div class="col-span-2">
<div class="flex justify-between">
<p
class="text-sm font-medium min-w-0 overflow-ellipsis overflow-hidden flex-1 text-text-primary">
class="text-xs min-w-0 overflow-ellipsis overflow-hidden flex-1 text-text-secondary">
{{ name }}
</p>
<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>
<span class="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
</span>
<span class="text-green-500 text-sm block pb-0.5"> working </span>
<span class="text-green-500 font-medium text-sm block pb-0.5"> working </span>
</div>
</div>
<div
class="text-text-secondary text-sm text-ellipsis whitespace-nowrap max-w-full overflow-hidden">
class="text-text-secondary text-sm font-medium text-ellipsis whitespace-nowrap max-w-full overflow-hidden">
{{ description }}
</div>
</div>

View File

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

View File

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

View File

@@ -28,7 +28,7 @@ const open = useSessionStorage('nav-collapse-state-' + props.title, true);
<CollapsibleRoot v-else v-model:open="open"
><CollapsibleTrigger class="w-full group py-0.5">
<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-regular 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-medium text-sm items-center justify-between">
<div class="flex items-center gap-x-2">
<component
:is="icon"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -32,7 +32,7 @@ const sizeClasses = {
:disabled="loading"
:class="
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-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',
'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',
sizeClasses[props.size],
props.class
)

View File

@@ -22,7 +22,7 @@ const emit = defineEmits<{
</script>
<template>
<div class="flex items-center justify-between bg-default-background px-2 py-1.5">
<div class="flex items-center justify-between bg-background px-2 py-1.5">
<!-- Left: Navigation -->
<div class="flex items-center gap-1">
<Button

View File

@@ -494,7 +494,7 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
<div
class="fc-header-scroll flex border-b border-border shrink-0 sticky top-0 z-10 bg-default-background">
<div
class="shrink-0 bg-default-background border-r border-border"
class="shrink-0 bg-background border-r border-border"
:style="{
width: TIME_AXIS_WIDTH + 'px',
minWidth: TIME_AXIS_WIDTH + 'px',
@@ -526,7 +526,7 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
<div ref="scrollerRef" class="fc-scroller">
<div class="flex min-w-0">
<div
class="shrink-0 bg-default-background border-r border-border"
class="shrink-0 bg-background border-r border-border"
:style="{
width: TIME_AXIS_WIDTH + 'px',
minWidth: TIME_AXIS_WIDTH + 'px',
@@ -553,7 +553,7 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
class="flex-1 min-w-0 relative"
@pointerdown="guardedSlotPointerDown($event)">
<div
class="bg-default-background relative"
class="bg-background relative"
:style="{ height: totalGridHeight + 'px' }">
<div
class="absolute inset-0 grid"

View File

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

View File

@@ -32,10 +32,10 @@ const displaysPlaceholder = computed(() => {
<template>
<div class="relative min-w-0 text-ellipsis whitespace-nowrap overflow-hidden">
<div class="relative text-sm min-w-0">
<div class="relative text-sm font-medium min-w-0">
<div
:class="[
'opacity-0 h-4 text-sm whitespace-pre min-w-0 pl-1.5 @lg:pl-3 pr-1',
'opacity-0 h-4 text-sm whitespace-pre font-medium min-w-0 pl-1.5 @lg:pl-3 pr-1',
{ 'min-w-[130px]': displaysPlaceholder },
]">
{{ liveDataValue }}
@@ -44,7 +44,7 @@ const displaysPlaceholder = computed(() => {
data-testid="time_entry_description"
:value="liveDataValue"
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 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 font-medium bg-transparent focus-visible:ring-0 rounded-lg border-0"
@blur="onChange"
@input="onInput"
@keydown.enter="onChange" />

View File

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

View File

@@ -36,13 +36,15 @@ const organization = inject<ComputedRef<Organization>>('organization');
:class="
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',
showDate ? 'text-xs py-1.5 font-medium' : 'text-sm py-1.5 font-normal',
showDate
? 'text-xs py-1.5 font-semibold'
: 'text-sm py-1.5 font-medium',
organization?.time_format === '12-hours' ? 'w-[160px]' : 'w-[100px]',
open && 'border-card-border bg-card-background'
)
">
{{ formatStartEnd(start, end, organization?.time_format) }}
<span v-if="showDate" class="text-text-tertiary font-normal"
<span v-if="showDate" class="text-text-tertiary font-medium"
>{{ formatDateLocalized(start, organization?.date_format) }}
</span>
</button>

View File

@@ -52,7 +52,6 @@ const props = defineProps<{
selected?: boolean;
canCreateProject: boolean;
enableEstimatedTime: boolean;
isReport?: boolean;
}>();
const emit = defineEmits<{ selected: []; unselected: [] }>();
@@ -149,7 +148,8 @@ async function handleDeleteTimeEntry() {
:task="timeEntry.task_id"
@changed="updateProjectAndTask"></TimeTrackerProjectTaskDropdown>
</div>
<div class="hidden @lg:flex items-center space-x-1 @lg:space-x-2 shrink-0">
<div
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">
{{ memberName }}
</div>
@@ -173,7 +173,6 @@ async function handleDeleteTimeEntry() {
<TimeEntryRowDurationInput
:start="timeEntry.start"
:end="timeEntry.end"
:is-report="props.isReport"
@changed="updateStartEndTime"></TimeEntryRowDurationInput>
<TimeTrackerStartStop
:active="!!(timeEntry.start && !timeEntry.end)"
@@ -198,7 +197,6 @@ async function handleDeleteTimeEntry() {
<TimeEntryRowDurationInput
:start="timeEntry.start"
:end="timeEntry.end"
:is-report="props.isReport"
@changed="updateStartEndTime"></TimeEntryRowDurationInput>
</div>
<!-- Second row: project/task - tags - billable - start - more -->

View File

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

View File

@@ -47,14 +47,14 @@ function selectUnselectAll(value: boolean) {
class="group-hover:block hidden"
@update:checked="selectUnselectAll"></Checkbox>
</div>
<span class="text-text-primary">
<span class="font-medium text-text-secondary">
{{ formatWeekday(date) }}
</span>
<span class="text-text-secondary ml-2">
<span class="text-text-tertiary ml-2">
{{ formatDate(date, organization?.date_format) }}
</span>
</div>
<div class="text-text-primary pr-2 @lg:pr-[92px]">
<div class="text-text-secondary pr-2 @lg:pr-[92px]">
<span class="font-medium">
{{
formatHumanReadableDuration(

View File

@@ -215,7 +215,7 @@ useSelectEvents(
v-model="tempDescription"
placeholder="What are you working on?"
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 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 font-medium focus:ring-0 transition"
type="text"
@keydown.enter="startTimerIfNotActive"
@keydown.esc="showDropdown = false"

View File

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

View File

@@ -14,30 +14,28 @@
@tailwind utilities;
:root.dark {
/* Linear/Raycast: cool blue-tinted neutrals, content surface at Material floor */
--color-bg-primary: oklch(0.17 0 0); /* content surface */
--color-bg-secondary: oklch(0.20 0 0); /* cards, input fills */
--color-bg-tertiary: oklch(0.25 0 0); /* hover / active row */
--color-bg-quaternary: oklch(0.29 0 0); /* pressed / selected */
--color-bg-elevated: oklch(0.22 0 0); /* popovers, modals, dropdowns */
--color-bg-background: oklch(0.14 0 0); /* sidebar / chrome — recessed */
--color-text-primary: oklch(0.97 0 0);
--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-bg-primary: oklch(0.14 0.0041 285.97);
--color-bg-secondary: oklch(0.18 0.005 285.97);
--color-bg-tertiary: oklch(0.22 0.0112 285.97);
--color-bg-quaternary: oklch(0.26 0.015 285.97);
--color-bg-background: oklch(0.1 0 0);
--color-text-primary: #ffffff;
--color-text-secondary: #e3e4e6;
--color-text-tertiary: #969799;
--color-text-quaternary: #595a5c;
--color-border-primary: oklch(0.24 0 0); /* separators — above elevated */
--color-border-secondary: oklch(0.28 0 0); /* card borders */
--color-border-tertiary: oklch(0.32 0 0); /* input borders */
--color-border-quaternary: oklch(0.36 0 0); /* emphasized borders */
--color-input-border-active: rgba(255, 255, 255, 0.18);
--color-border-primary: #191b1f;
--color-border-secondary: oklch(0.25 0.0098 268.31);
--color-border-tertiary: #2c2e33;
--color-border-quaternary: #393b42;
--color-input-border-active: rgba(255, 255, 255, 0.15);
--theme-color-chart: var(--color-accent-200);
--theme-color-menu-active: 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-dropdown: 0 8px 24px -4px rgb(0 0 0 / 55%), 0 2px 6px 0 rgb(0 0 0 / 35%);
--theme-shadow-dropdown: 0 4px 7px 0px rgb(0 0 0 / 40%);
--theme-color-card-background-active: var(--color-bg-tertiary);
@@ -189,7 +187,7 @@ body {
--foreground: var(--color-text-primary);
--card: var(--theme-color-card-background);
--card-foreground: var(--color-text-primary);
--popover: var(--color-bg-elevated, var(--color-bg-secondary));
--popover: var(--color-bg-secondary);
--popover-foreground: var(--color-text-primary);
--primary: var(--color-bg-primary);
--primary-foreground: var(--color-text-primary);

View File

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

View File

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

View File

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

View File

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

View File

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