From 89ed62780069f35f76ab37f7f2f0a44f541e5b0a Mon Sep 17 00:00:00 2001 From: Weijie Zhao Date: Mon, 8 Jun 2026 13:09:57 +0800 Subject: [PATCH 001/214] fix(auth): remove stale OIDC callback lock The OpenID callback view used a localStorage "authenticating" flag to avoid submitting the same authorization code twice when the route was remounted during an auth layout swap. That layout swap is now guarded by AUTH_ROUTE_NAMES, so openid.auth stays in the unauthenticated shell until redirectIfSaved() navigates away. The persistent flag can instead get stranded when the page is refreshed, closed, or interrupted during the callback, making future OIDC callbacks silently return before exchanging the code. Remove the flag so each valid callback URL is processed normally while keeping the existing state validation and TOTP retry handling. --- frontend/src/views/user/OpenIdAuth.vue | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/frontend/src/views/user/OpenIdAuth.vue b/frontend/src/views/user/OpenIdAuth.vue index b163ba4e5..3d41c4f0f 100644 --- a/frontend/src/views/user/OpenIdAuth.vue +++ b/frontend/src/views/user/OpenIdAuth.vue @@ -90,25 +90,11 @@ function findProvider(providerKey: string): IProvider | undefined { } async function authenticateWithCode() { - // This component gets mounted twice: The first time when the actual auth request hits the frontend, - // the second time after that auth request succeeded and the outer component "content-no-auth" isn't used - // but instead the "content-auth" component is used. Because this component is just a route and thus - // gets mounted as part of a which both the content-auth and content-no-auth components have, - // this re-mounts the component, even if the user is already authenticated. - // To make sure we only try to authenticate the user once, we set this "authenticating" lock in localStorage - // which ensures only one auth request is done at a time. We don't simply check if the user is already - // authenticated to not prevent the whole authentication if some user is already logged in. - if (localStorage.getItem('authenticating')) { - return - } - localStorage.setItem('authenticating', 'true') - errorMessage.value = '' const providerKey = route.params.provider as string if (typeof route.query.error !== 'undefined') { - localStorage.removeItem('authenticating') sessionStorage.removeItem(pendingTotpKey(providerKey)) errorMessage.value = typeof route.query.message !== 'undefined' ? route.query.message as string @@ -118,7 +104,6 @@ async function authenticateWithCode() { const state = localStorage.getItem('state') if (typeof route.query.state === 'undefined' || route.query.state !== state) { - localStorage.removeItem('authenticating') sessionStorage.removeItem(pendingTotpKey(providerKey)) errorMessage.value = t('user.auth.openIdStateError') return @@ -145,8 +130,6 @@ async function authenticateWithCode() { return } errorMessage.value = getErrorText(e) - } finally { - localStorage.removeItem('authenticating') } } From 8ff97a61dea78e0a35b6f6c4f09d5de6e033b04a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 8 Jun 2026 04:53:54 +0000 Subject: [PATCH 002/214] chore(deps): update dev-dependencies --- desktop/package.json | 2 +- desktop/pnpm-lock.yaml | 439 +++++++--------------------------------- frontend/package.json | 2 +- frontend/pnpm-lock.yaml | 18 +- 4 files changed, 83 insertions(+), 378 deletions(-) diff --git a/desktop/package.json b/desktop/package.json index 9dc1e98e0..4a2b83f75 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -62,7 +62,7 @@ }, "devDependencies": { "electron": "40.10.2", - "electron-builder": "26.15.0", + "electron-builder": "26.15.2", "unzipper": "0.12.3" }, "dependencies": { diff --git a/desktop/pnpm-lock.yaml b/desktop/pnpm-lock.yaml index 6481b9c32..7b7b94c6c 100644 --- a/desktop/pnpm-lock.yaml +++ b/desktop/pnpm-lock.yaml @@ -24,8 +24,8 @@ importers: specifier: 40.10.2 version: 40.10.2 electron-builder: - specifier: 26.15.0 - version: 26.15.0(electron-builder-squirrel-windows@24.13.3) + specifier: 26.15.2 + version: 26.15.2(electron-builder-squirrel-windows@24.13.3) unzipper: specifier: 0.12.3 version: 0.12.3 @@ -74,8 +74,8 @@ packages: engines: {node: '>=12.0.0'} hasBin: true - '@electron/rebuild@4.0.3': - resolution: {integrity: sha512-u9vpTHRMkOYCs/1FLiSVAFZ7FbjsXK+bQuzviJZa+lG7BHZl1nz52/IcGvwa3sk80/fc3llutBkbCq10Vh8WQA==} + '@electron/rebuild@4.0.4': + resolution: {integrity: sha512-Rzc39XPdk/+/wBG8MfwAHohXflep0ITUfulb6Rgz3R0NeSB1noE+E9/M/cb8ftCAiyDD9PPhLuuWgE1GaInbKg==} engines: {node: '>=22.12.0'} hasBin: true @@ -115,14 +115,6 @@ packages: resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} engines: {node: '>= 20.19.0'} - '@npmcli/agent@3.0.0': - resolution: {integrity: sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q==} - engines: {node: ^18.17.0 || >=20.5.0} - - '@npmcli/fs@4.0.0': - resolution: {integrity: sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q==} - engines: {node: ^18.17.0 || >=20.5.0} - '@peculiar/asn1-schema@2.7.0': resolution: {integrity: sha512-W8ZfWzLmQnrcky+eh3tni4IozMdqBDiHWU0N+vve/UGjMaUs8c0L7A2oEdkBXS8rTpWDpK/aoI3DG/L/hxmxPg==} @@ -188,9 +180,9 @@ packages: resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} engines: {node: '>=14.6'} - abbrev@3.0.1: - resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==} - engines: {node: ^18.17.0 || >=20.5.0} + abbrev@4.0.0: + resolution: {integrity: sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==} + engines: {node: ^20.17.0 || >=22.9.0} accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} @@ -241,12 +233,12 @@ packages: dmg-builder: 24.13.3 electron-builder-squirrel-windows: 24.13.3 - app-builder-lib@26.15.0: - resolution: {integrity: sha512-j2+P6Lh+l/VuWfXZWSs7u+OAPqYJQGnZZO30M833XQQaRuyohm4RZk7Gw4nQXfeyQH9GqXaTwR16Y0LaVTlS+g==} + app-builder-lib@26.15.2: + resolution: {integrity: sha512-3mYfKOjr/ZY7gFESOcq8kylBMgGPpmlQYnpBVit4p6zIg0t/8bkWBILdMMtnjFyN2jllyBf225T8dLlz3D6oBQ==} engines: {node: '>=14.0.0'} peerDependencies: - dmg-builder: 26.15.0 - electron-builder-squirrel-windows: 26.15.0 + dmg-builder: 26.15.2 + electron-builder-squirrel-windows: 26.15.2 archiver-utils@2.1.0: resolution: {integrity: sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==} @@ -348,10 +340,6 @@ packages: resolution: {integrity: sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==} engines: {node: '>=6.0.0'} - cacache@19.0.1: - resolution: {integrity: sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ==} - engines: {node: ^18.17.0 || >=20.5.0} - cacheable-lookup@5.0.4: resolution: {integrity: sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==} engines: {node: '>=10.6.0'} @@ -387,14 +375,6 @@ packages: resolution: {integrity: sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==} engines: {node: '>=8'} - cli-cursor@3.1.0: - resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} - engines: {node: '>=8'} - - cli-spinners@2.9.2: - resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} - engines: {node: '>=6'} - cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -402,10 +382,6 @@ packages: clone-response@1.0.3: resolution: {integrity: sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==} - clone@1.0.4: - resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} - engines: {node: '>=0.8'} - color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -477,9 +453,6 @@ packages: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} engines: {node: '>=10'} - defaults@1.0.4: - resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} - defer-to-connect@2.0.1: resolution: {integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==} engines: {node: '>=10'} @@ -500,10 +473,6 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} - detect-libc@2.0.3: - resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==} - engines: {node: '>=8'} - detect-node@2.1.0: resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} @@ -513,8 +482,8 @@ packages: dir-compare@4.2.0: resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==} - dmg-builder@26.15.0: - resolution: {integrity: sha512-oS8MWttbpIUF/2v8LOEY+f4ayL84ipMOarZvdRMl/pxlhLxAYjYMklTXHEXIl37Ig+qJv/bVF7HgyIoOoZyMWA==} + dmg-builder@26.15.2: + resolution: {integrity: sha512-fMkjRqKyPtsz4Kzu/qGP0BGjqzMCIgp+/7kw/u6YH6lvn/8hvL3c0TXhoFayBoYdpPCnEinnCHztd4bW7/jetA==} dotenv-expand@11.0.6: resolution: {integrity: sha512-8NHi73otpWsZGBSZwwknTXS5pqMOrk9+Ssrna8xCaxkzEpU9OTf9R5ArQGVw03//Zmk9MOwLPng9WwndvpAJ5g==} @@ -552,16 +521,16 @@ packages: electron-builder-squirrel-windows@24.13.3: resolution: {integrity: sha512-oHkV0iogWfyK+ah9ZIvMDpei1m9ZRpdXcvde1wTpra2U8AFDNNpqJdnin5z+PM1GbQ5BoaKCWas2HSjtR0HwMg==} - electron-builder@26.15.0: - resolution: {integrity: sha512-zd4cfvjHmtyGqMaDudg5rAjNUkwIJDz8ICaCsz77hFKcjMQHcZNNNCs/C4phwN9+gEVwmhvpKMzNFum6fs/n6A==} + electron-builder@26.15.2: + resolution: {integrity: sha512-veKM9+dCljaC5A74Pwc0ZWQ9arOHREXWh9hUIf8NGg49ch7x+IB4QhbMzIrV5ONZIXM2OEkaxW11cAPjPtoi4A==} engines: {node: '>=14.0.0'} hasBin: true electron-publish@24.13.1: resolution: {integrity: sha512-2ZgdEqJ8e9D17Hwp5LEq5mLQPjqU3lv/IALvgp+4W8VeNhryfGhYEQC/PgDPMrnWUp+l60Ou5SJLsu+k4mhQ8A==} - electron-publish@26.15.0: - resolution: {integrity: sha512-pt6K3ol/a+o3HbqmYkL2NYlVH5pd34tL4FPRcgX8E88xQAqQyIsseXe4vWy7Pq2BaYy+iFGJrtInZe11FFAQwQ==} + electron-publish@26.15.1: + resolution: {integrity: sha512-BMgMHOyexWn0UnOC+Afffw0DMrr0yfLp4U8YsLXwoJ3Da7LS7WUnz21teYZqO0gaApE1KgsjREWmbPqvF5JcPg==} electron@40.10.2: resolution: {integrity: sha512-Xj3Hy0Imbu4g0gDIW55w/jJYz94nMO2JRSGYA3LyAn5SwaERCelgZrA21vfH+Bi//SWAWQXddHsMwCqauyMT8g==} @@ -578,9 +547,6 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} - encoding@0.1.13: - resolution: {integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==} - end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} @@ -704,10 +670,6 @@ packages: resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} engines: {node: '>=10'} - fs-minipass@3.0.3: - resolution: {integrity: sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} @@ -808,10 +770,6 @@ packages: resolution: {integrity: sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==} engines: {node: '>= 14'} - iconv-lite@0.6.3: - resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} - engines: {node: '>=0.10.0'} - iconv-lite@0.7.0: resolution: {integrity: sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==} engines: {node: '>=0.10.0'} @@ -819,10 +777,6 @@ packages: ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - imurmurhash@0.1.4: - resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} - engines: {node: '>=0.8.19'} - inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. @@ -830,10 +784,6 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - ip-address@10.2.0: - resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} - engines: {node: '>= 12'} - ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -846,17 +796,9 @@ packages: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} - is-interactive@1.0.0: - resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} - engines: {node: '>=8'} - is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} - is-unicode-supported@0.1.0: - resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} - engines: {node: '>=10'} - isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} @@ -875,6 +817,10 @@ packages: resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==} engines: {node: '>=16'} + isexe@4.0.0: + resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==} + engines: {node: '>=20'} + jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} @@ -945,10 +891,6 @@ packages: lodash@4.18.1: resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} - log-symbols@4.1.0: - resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} - engines: {node: '>=10'} - lowercase-keys@2.0.0: resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==} engines: {node: '>=8'} @@ -960,10 +902,6 @@ packages: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} - make-fetch-happen@14.0.3: - resolution: {integrity: sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ==} - engines: {node: ^18.17.0 || >=20.5.0} - matcher@3.0.0: resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==} engines: {node: '>=10'} @@ -1001,10 +939,6 @@ packages: engines: {node: '>=4.0.0'} hasBin: true - mimic-fn@2.1.0: - resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} - engines: {node: '>=6'} - mimic-response@1.0.1: resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==} engines: {node: '>=4'} @@ -1020,30 +954,6 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - minipass-collect@2.0.1: - resolution: {integrity: sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==} - engines: {node: '>=16 || 14 >=14.17'} - - minipass-fetch@4.0.1: - resolution: {integrity: sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ==} - engines: {node: ^18.17.0 || >=20.5.0} - - minipass-flush@1.0.5: - resolution: {integrity: sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==} - engines: {node: '>= 8'} - - minipass-pipeline@1.2.4: - resolution: {integrity: sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==} - engines: {node: '>=8'} - - minipass-sized@1.0.3: - resolution: {integrity: sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==} - engines: {node: '>=8'} - - minipass@3.3.6: - resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} - engines: {node: '>=8'} - minipass@7.1.3: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} @@ -1066,17 +976,17 @@ packages: node-api-version@0.2.1: resolution: {integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==} - node-gyp@11.5.0: - resolution: {integrity: sha512-ra7Kvlhxn5V9Slyus0ygMa2h+UqExPqUIkfk7Pc8QTLT956JLSy51uWFwHtIYy0vI8cB4BDhc/S03+880My/LQ==} - engines: {node: ^18.17.0 || >=20.5.0} + node-gyp@12.4.0: + resolution: {integrity: sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==} + engines: {node: ^20.17.0 || >=22.9.0} hasBin: true node-int64@0.4.0: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} - nopt@8.1.0: - resolution: {integrity: sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==} - engines: {node: ^18.17.0 || >=20.5.0} + nopt@9.0.0: + resolution: {integrity: sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==} + engines: {node: ^20.17.0 || >=22.9.0} hasBin: true normalize-path@3.0.0: @@ -1102,14 +1012,6 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - onetime@5.1.2: - resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} - engines: {node: '>=6'} - - ora@5.4.1: - resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} - engines: {node: '>=10'} - p-cancelable@2.1.1: resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} engines: {node: '>=8'} @@ -1118,10 +1020,6 @@ packages: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} - p-map@7.0.4: - resolution: {integrity: sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==} - engines: {node: '>=18'} - package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -1167,9 +1065,9 @@ packages: resolution: {integrity: sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==} engines: {node: '>=10.4.0'} - proc-log@5.0.0: - resolution: {integrity: sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==} - engines: {node: ^18.17.0 || >=20.5.0} + proc-log@6.1.0: + resolution: {integrity: sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==} + engines: {node: ^20.17.0 || >=22.9.0} process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} @@ -1255,10 +1153,6 @@ packages: responselike@2.0.1: resolution: {integrity: sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==} - restore-cursor@3.1.0: - resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} - engines: {node: '>=8'} - retry@0.12.0: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} @@ -1358,18 +1252,6 @@ packages: resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} engines: {node: '>=10'} - smart-buffer@4.2.0: - resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} - engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} - - socks-proxy-agent@8.0.5: - resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} - engines: {node: '>= 14'} - - socks@2.8.7: - resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==} - engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} - source-map-support@0.5.21: resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} @@ -1380,10 +1262,6 @@ packages: sprintf-js@1.1.3: resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} - ssri@12.0.0: - resolution: {integrity: sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ==} - engines: {node: ^18.17.0 || >=20.5.0} - stat-mode@1.0.0: resolution: {integrity: sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==} engines: {node: '>= 6'} @@ -1473,13 +1351,9 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} - unique-filename@4.0.0: - resolution: {integrity: sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ==} - engines: {node: ^18.17.0 || >=20.5.0} - - unique-slug@5.0.0: - resolution: {integrity: sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg==} - engines: {node: ^18.17.0 || >=20.5.0} + undici@6.26.0: + resolution: {integrity: sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==} + engines: {node: '>=18.17'} universalify@0.1.2: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} @@ -1509,9 +1383,6 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} - wcwidth@1.0.1: - resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} - webcrypto-core@1.9.2: resolution: {integrity: sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q==} @@ -1525,6 +1396,11 @@ packages: engines: {node: ^18.17.0 || >=20.5.0} hasBin: true + which@6.0.1: + resolution: {integrity: sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -1657,21 +1533,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@electron/rebuild@4.0.3': + '@electron/rebuild@4.0.4': dependencies: '@malept/cross-spawn-promise': 2.0.0 debug: 4.4.3 - detect-libc: 2.0.3 - got: 11.8.6 - graceful-fs: 4.2.11 node-abi: 4.24.0 node-api-version: 0.2.1 - node-gyp: 11.5.0 - ora: 5.4.1 + node-gyp: 12.4.0 read-binary-file-arch: 1.0.6 - semver: 7.8.1 - tar: 7.5.15 - yargs: 17.7.2 transitivePeerDependencies: - supports-color @@ -1733,20 +1602,6 @@ snapshots: '@noble/hashes@2.2.0': {} - '@npmcli/agent@3.0.0': - dependencies: - agent-base: 7.1.4 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.5 - lru-cache: 10.4.3 - socks-proxy-agent: 8.0.5 - transitivePeerDependencies: - - supports-color - - '@npmcli/fs@4.0.0': - dependencies: - semver: 7.8.1 - '@peculiar/asn1-schema@2.7.0': dependencies: '@peculiar/utils': 2.0.3 @@ -1820,7 +1675,7 @@ snapshots: '@xmldom/xmldom@0.9.10': {} - abbrev@3.0.1: {} + abbrev@4.0.0: {} accepts@2.0.0: dependencies: @@ -1865,7 +1720,7 @@ snapshots: app-builder-bin@4.0.0: {} - app-builder-lib@24.13.3(dmg-builder@26.15.0)(electron-builder-squirrel-windows@24.13.3): + app-builder-lib@24.13.3(dmg-builder@26.15.2)(electron-builder-squirrel-windows@24.13.3): dependencies: '@develar/schema-utils': 2.6.5 '@electron/notarize': 2.2.1 @@ -1879,9 +1734,9 @@ snapshots: builder-util-runtime: 9.2.4 chromium-pickle-js: 0.2.0 debug: 4.4.3 - dmg-builder: 26.15.0(electron-builder-squirrel-windows@24.13.3) + dmg-builder: 26.15.2(electron-builder-squirrel-windows@24.13.3) ejs: 3.1.10 - electron-builder-squirrel-windows: 24.13.3(dmg-builder@26.15.0) + electron-builder-squirrel-windows: 24.13.3(dmg-builder@26.15.2) electron-publish: 24.13.1 form-data: 4.0.5 fs-extra: 10.1.0 @@ -1899,14 +1754,14 @@ snapshots: transitivePeerDependencies: - supports-color - app-builder-lib@26.15.0(dmg-builder@26.15.0)(electron-builder-squirrel-windows@24.13.3): + app-builder-lib@26.15.2(dmg-builder@26.15.2)(electron-builder-squirrel-windows@24.13.3): dependencies: '@electron/asar': 3.4.1 '@electron/fuses': 1.8.0 '@electron/get': 3.1.0 '@electron/notarize': 2.5.0 '@electron/osx-sign': 1.3.3 - '@electron/rebuild': 4.0.3 + '@electron/rebuild': 4.0.4 '@electron/universal': 2.0.3 '@malept/flatpak-bundler': 0.4.0 '@noble/hashes': 2.2.0 @@ -1920,12 +1775,12 @@ snapshots: chromium-pickle-js: 0.2.0 ci-info: 4.3.1 debug: 4.4.3 - dmg-builder: 26.15.0(electron-builder-squirrel-windows@24.13.3) + dmg-builder: 26.15.2(electron-builder-squirrel-windows@24.13.3) dotenv: 16.4.5 dotenv-expand: 11.0.6 ejs: 3.1.10 - electron-builder-squirrel-windows: 24.13.3(dmg-builder@26.15.0) - electron-publish: 26.15.0 + electron-builder-squirrel-windows: 24.13.3(dmg-builder@26.15.2) + electron-publish: 26.15.1 fs-extra: 10.1.0 hosted-git-info: 4.1.0 isbinaryfile: 5.0.7 @@ -2107,21 +1962,6 @@ snapshots: bytestreamjs@2.0.1: {} - cacache@19.0.1: - dependencies: - '@npmcli/fs': 4.0.0 - fs-minipass: 3.0.3 - glob: 10.5.0 - lru-cache: 10.4.3 - minipass: 7.1.3 - minipass-collect: 2.0.1 - minipass-flush: 1.0.5 - minipass-pipeline: 1.2.4 - p-map: 7.0.4 - ssri: 12.0.0 - tar: 7.5.15 - unique-filename: 4.0.0 - cacheable-lookup@5.0.4: {} cacheable-request@7.0.4: @@ -2157,12 +1997,6 @@ snapshots: ci-info@4.3.1: {} - cli-cursor@3.1.0: - dependencies: - restore-cursor: 3.1.0 - - cli-spinners@2.9.2: {} - cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -2173,8 +2007,6 @@ snapshots: dependencies: mimic-response: 1.0.1 - clone@1.0.4: {} - color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -2232,10 +2064,6 @@ snapshots: dependencies: mimic-response: 3.1.0 - defaults@1.0.4: - dependencies: - clone: 1.0.4 - defer-to-connect@2.0.1: {} define-data-property@1.1.4: @@ -2256,8 +2084,6 @@ snapshots: depd@2.0.0: {} - detect-libc@2.0.3: {} - detect-node@2.1.0: optional: true @@ -2271,9 +2097,9 @@ snapshots: minimatch: 10.2.5 p-limit: 3.1.0 - dmg-builder@26.15.0(electron-builder-squirrel-windows@24.13.3): + dmg-builder@26.15.2(electron-builder-squirrel-windows@24.13.3): dependencies: - app-builder-lib: 26.15.0(dmg-builder@26.15.0)(electron-builder-squirrel-windows@24.13.3) + app-builder-lib: 26.15.2(dmg-builder@26.15.2)(electron-builder-squirrel-windows@24.13.3) builder-util: 26.15.0 fs-extra: 10.1.0 js-yaml: 4.1.1 @@ -2309,9 +2135,9 @@ snapshots: dependencies: jake: 10.8.7 - electron-builder-squirrel-windows@24.13.3(dmg-builder@26.15.0): + electron-builder-squirrel-windows@24.13.3(dmg-builder@26.15.2): dependencies: - app-builder-lib: 24.13.3(dmg-builder@26.15.0)(electron-builder-squirrel-windows@24.13.3) + app-builder-lib: 24.13.3(dmg-builder@26.15.2)(electron-builder-squirrel-windows@24.13.3) archiver: 5.3.2 builder-util: 24.13.1 fs-extra: 10.1.0 @@ -2319,14 +2145,14 @@ snapshots: - dmg-builder - supports-color - electron-builder@26.15.0(electron-builder-squirrel-windows@24.13.3): + electron-builder@26.15.2(electron-builder-squirrel-windows@24.13.3): dependencies: - app-builder-lib: 26.15.0(dmg-builder@26.15.0)(electron-builder-squirrel-windows@24.13.3) + app-builder-lib: 26.15.2(dmg-builder@26.15.2)(electron-builder-squirrel-windows@24.13.3) builder-util: 26.15.0 builder-util-runtime: 9.7.0 chalk: 4.1.2 ci-info: 4.3.1 - dmg-builder: 26.15.0(electron-builder-squirrel-windows@24.13.3) + dmg-builder: 26.15.2(electron-builder-squirrel-windows@24.13.3) fs-extra: 10.1.0 lazy-val: 1.0.5 simple-update-notifier: 2.0.0 @@ -2347,7 +2173,7 @@ snapshots: transitivePeerDependencies: - supports-color - electron-publish@26.15.0: + electron-publish@26.15.1: dependencies: '@types/fs-extra': 9.0.13 aws4: 1.13.2 @@ -2375,11 +2201,6 @@ snapshots: encodeurl@2.0.0: {} - encoding@0.1.13: - dependencies: - iconv-lite: 0.6.3 - optional: true - end-of-stream@1.4.5: dependencies: once: 1.4.0 @@ -2539,10 +2360,6 @@ snapshots: jsonfile: 6.2.0 universalify: 2.0.1 - fs-minipass@3.0.3: - dependencies: - minipass: 7.1.3 - fs.realpath@1.0.0: {} function-bind@1.1.2: {} @@ -2688,19 +2505,12 @@ snapshots: transitivePeerDependencies: - supports-color - iconv-lite@0.6.3: - dependencies: - safer-buffer: 2.1.2 - optional: true - iconv-lite@0.7.0: dependencies: safer-buffer: 2.1.2 ieee754@1.2.1: {} - imurmurhash@0.1.4: {} - inflight@1.0.6: dependencies: once: 1.4.0 @@ -2708,8 +2518,6 @@ snapshots: inherits@2.0.4: {} - ip-address@10.2.0: {} - ipaddr.js@1.9.1: {} is-ci@3.0.1: @@ -2718,12 +2526,8 @@ snapshots: is-fullwidth-code-point@3.0.0: {} - is-interactive@1.0.0: {} - is-promise@4.0.0: {} - is-unicode-supported@0.1.0: {} - isarray@1.0.0: {} isbinaryfile@4.0.10: {} @@ -2734,6 +2538,8 @@ snapshots: isexe@3.1.1: {} + isexe@4.0.0: {} + jackspeak@3.4.3: dependencies: '@isaacs/cliui': 8.0.2 @@ -2802,11 +2608,6 @@ snapshots: lodash@4.18.1: {} - log-symbols@4.1.0: - dependencies: - chalk: 4.1.2 - is-unicode-supported: 0.1.0 - lowercase-keys@2.0.0: {} lru-cache@10.4.3: {} @@ -2815,22 +2616,6 @@ snapshots: dependencies: yallist: 4.0.0 - make-fetch-happen@14.0.3: - dependencies: - '@npmcli/agent': 3.0.0 - cacache: 19.0.1 - http-cache-semantics: 4.2.0 - minipass: 7.1.3 - minipass-fetch: 4.0.1 - minipass-flush: 1.0.5 - minipass-pipeline: 1.2.4 - negotiator: 1.0.0 - proc-log: 5.0.0 - promise-retry: 2.0.1 - ssri: 12.0.0 - transitivePeerDependencies: - - supports-color - matcher@3.0.0: dependencies: escape-string-regexp: 4.0.0 @@ -2856,8 +2641,6 @@ snapshots: mime@2.6.0: {} - mimic-fn@2.1.0: {} - mimic-response@1.0.1: {} mimic-response@3.1.0: {} @@ -2868,34 +2651,6 @@ snapshots: minimist@1.2.8: {} - minipass-collect@2.0.1: - dependencies: - minipass: 7.1.3 - - minipass-fetch@4.0.1: - dependencies: - minipass: 7.1.3 - minipass-sized: 1.0.3 - minizlib: 3.1.0 - optionalDependencies: - encoding: 0.1.13 - - minipass-flush@1.0.5: - dependencies: - minipass: 3.3.6 - - minipass-pipeline@1.2.4: - dependencies: - minipass: 3.3.6 - - minipass-sized@1.0.3: - dependencies: - minipass: 3.3.6 - - minipass@3.3.6: - dependencies: - yallist: 4.0.0 - minipass@7.1.3: {} minizlib@3.1.0: @@ -2914,26 +2669,24 @@ snapshots: dependencies: semver: 7.8.1 - node-gyp@11.5.0: + node-gyp@12.4.0: dependencies: env-paths: 2.2.1 exponential-backoff: 3.1.3 graceful-fs: 4.2.11 - make-fetch-happen: 14.0.3 - nopt: 8.1.0 - proc-log: 5.0.0 + nopt: 9.0.0 + proc-log: 6.1.0 semver: 7.8.1 tar: 7.5.15 tinyglobby: 0.2.15 - which: 5.0.0 - transitivePeerDependencies: - - supports-color + undici: 6.26.0 + which: 6.0.1 node-int64@0.4.0: {} - nopt@8.1.0: + nopt@9.0.0: dependencies: - abbrev: 3.0.1 + abbrev: 4.0.0 normalize-path@3.0.0: {} @@ -2952,30 +2705,12 @@ snapshots: dependencies: wrappy: 1.0.2 - onetime@5.1.2: - dependencies: - mimic-fn: 2.1.0 - - ora@5.4.1: - dependencies: - bl: 4.1.0 - chalk: 4.1.2 - cli-cursor: 3.1.0 - cli-spinners: 2.9.2 - is-interactive: 1.0.0 - is-unicode-supported: 0.1.0 - log-symbols: 4.1.0 - strip-ansi: 6.0.1 - wcwidth: 1.0.1 - p-cancelable@2.1.1: {} p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 - p-map@7.0.4: {} - package-json-from-dist@1.0.1: {} parseurl@1.3.3: {} @@ -3018,7 +2753,7 @@ snapshots: base64-js: 1.5.1 xmlbuilder: 15.1.1 - proc-log@5.0.0: {} + proc-log@6.1.0: {} process-nextick-args@2.0.1: {} @@ -3117,11 +2852,6 @@ snapshots: dependencies: lowercase-keys: 2.0.0 - restore-cursor@3.1.0: - dependencies: - onetime: 5.1.2 - signal-exit: 3.0.7 - retry@0.12.0: {} roarr@2.15.4: @@ -3241,21 +2971,6 @@ snapshots: dependencies: semver: 7.8.1 - smart-buffer@4.2.0: {} - - socks-proxy-agent@8.0.5: - dependencies: - agent-base: 7.1.4 - debug: 4.4.3 - socks: 2.8.7 - transitivePeerDependencies: - - supports-color - - socks@2.8.7: - dependencies: - ip-address: 10.2.0 - smart-buffer: 4.2.0 - source-map-support@0.5.21: dependencies: buffer-from: 1.1.2 @@ -3266,10 +2981,6 @@ snapshots: sprintf-js@1.1.3: optional: true - ssri@12.0.0: - dependencies: - minipass: 7.1.3 - stat-mode@1.0.0: {} statuses@2.0.2: {} @@ -3369,13 +3080,7 @@ snapshots: undici-types@7.16.0: {} - unique-filename@4.0.0: - dependencies: - unique-slug: 5.0.0 - - unique-slug@5.0.0: - dependencies: - imurmurhash: 0.1.4 + undici@6.26.0: {} universalify@0.1.2: {} @@ -3401,10 +3106,6 @@ snapshots: vary@1.1.2: {} - wcwidth@1.0.1: - dependencies: - defaults: 1.0.4 - webcrypto-core@1.9.2: dependencies: '@peculiar/asn1-schema': 2.7.0 @@ -3421,6 +3122,10 @@ snapshots: dependencies: isexe: 3.1.1 + which@6.0.1: + dependencies: + isexe: 4.0.0 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 diff --git a/frontend/package.json b/frontend/package.json index 7a4016cf1..d5204b933 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -155,7 +155,7 @@ "vite-plugin-vue-devtools": "8.1.2", "vite-svg-loader": "5.1.1", "vitest": "4.1.8", - "vue-tsc": "3.3.3", + "vue-tsc": "3.3.4", "wait-on": "9.0.10", "workbox-cli": "7.4.1", "ws": "8.21.0" diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 8f92ad279..624ffc07d 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -326,8 +326,8 @@ importers: specifier: 4.1.8 version: 4.1.8(@types/node@24.13.1)(happy-dom@20.10.2)(jsdom@27.4.0)(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) vue-tsc: - specifier: 3.3.3 - version: 3.3.3(typescript@5.9.3) + specifier: 3.3.4 + version: 3.3.4(typescript@5.9.3) wait-on: specifier: 9.0.10 version: 9.0.10 @@ -3129,8 +3129,8 @@ packages: typescript: optional: true - '@vue/language-core@3.3.3': - resolution: {integrity: sha512-X6p+7nfY7vVT6dQwUJ+v0Jfq/lwIfhL2jMi91dQ3ln4hnlGXlxsDu/FNkeyHYgvYtyQy18ZX76IZy7X4diDbiQ==} + '@vue/language-core@3.3.4': + resolution: {integrity: sha512-IuHqQ5zGGOE7CXP72VX6A42IVeIzYv4WAhO6arej11TRNqtdZfGyH8Yr2FOCaDX0dSQG+JwULLoFHGY1igYVjQ==} '@vue/reactivity@3.5.27': resolution: {integrity: sha512-vvorxn2KXfJ0nBEnj4GYshSgsyMNFnIQah/wczXlsNXt+ijhugmW+PpJ2cNPe4V6jpnBcs0MhCODKllWG+nvoQ==} @@ -6854,8 +6854,8 @@ packages: peerDependencies: vue: ^3.5.0 - vue-tsc@3.3.3: - resolution: {integrity: sha512-SWUEG7YRUeDJHT7Xsuhf02elYX2gxPzzAII7OxDAh4KNOr4QHQ0Lls0YfnaO5GNd560CwVa2HTfdqmA5MqvRqQ==} + vue-tsc@3.3.4: + resolution: {integrity: sha512-XA/JqmQwS2GZmfgpjOEGdrKwaTSEuPwxpHa7/t6f4yiGrJb3gVHTPb9wBfByMNZwQ+xDXs41b8gaS2DKsOozUw==} hasBin: true peerDependencies: typescript: '>=5.0.0' @@ -9980,7 +9980,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@vue/language-core@3.3.3': + '@vue/language-core@3.3.4': dependencies: '@volar/language-core': 2.4.28 '@vue/compiler-dom': 3.5.27 @@ -14086,10 +14086,10 @@ snapshots: '@vue/devtools-api': 6.6.4 vue: 3.5.27(typescript@5.9.3) - vue-tsc@3.3.3(typescript@5.9.3): + vue-tsc@3.3.4(typescript@5.9.3): dependencies: '@volar/typescript': 2.4.28 - '@vue/language-core': 3.3.3 + '@vue/language-core': 3.3.4 typescript: 5.9.3 vue@3.5.27(typescript@5.9.3): From 6387d8138aff072e67369bcf59030df5a976d38b Mon Sep 17 00:00:00 2001 From: kolaente Date: Mon, 8 Jun 2026 15:10:54 +0200 Subject: [PATCH 003/214] feat(time-tracking): add the time_entries table migration --- pkg/migration/20260607132257.go | 55 +++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 pkg/migration/20260607132257.go diff --git a/pkg/migration/20260607132257.go b/pkg/migration/20260607132257.go new file mode 100644 index 000000000..f2a7e76b3 --- /dev/null +++ b/pkg/migration/20260607132257.go @@ -0,0 +1,55 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package migration + +import ( + "time" + + "src.techknowlogick.com/xormigrate" + "xorm.io/xorm" +) + +// Mirrors models.TimeEntry. No partial unique index for the single-active-timer +// rule — MySQL has no filtered indexes; it's enforced in the model instead. +type TimeEntry20260607132257 struct { + ID int64 `xorm:"bigint autoincr not null unique pk"` + UserID int64 `xorm:"bigint not null INDEX"` + TaskID int64 `xorm:"bigint null INDEX"` + ProjectID int64 `xorm:"bigint null INDEX"` + StartTime time.Time `xorm:"not null INDEX"` + EndTime *time.Time `xorm:"null"` + Comment string `xorm:"text null"` + Created time.Time `xorm:"created not null"` + Updated time.Time `xorm:"updated not null"` +} + +func (TimeEntry20260607132257) TableName() string { + return "time_entries" +} + +func init() { + migrations = append(migrations, &xormigrate.Migration{ + ID: "20260607132257", + Description: "Add time_entries table", + Migrate: func(tx *xorm.Engine) error { + return tx.Sync(TimeEntry20260607132257{}) + }, + Rollback: func(tx *xorm.Engine) error { + return tx.DropTables(TimeEntry20260607132257{}) + }, + }) +} From 26c067cc3899ac77ea4bc3ba2250b71d4a5cb864 Mon Sep 17 00:00:00 2001 From: kolaente Date: Mon, 8 Jun 2026 15:10:55 +0200 Subject: [PATCH 004/214] refactor: extract preprocessFilterString from task filter parsing --- pkg/models/task_collection_filter.go | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/pkg/models/task_collection_filter.go b/pkg/models/task_collection_filter.go index 7d2970277..6828b0e07 100644 --- a/pkg/models/task_collection_filter.go +++ b/pkg/models/task_collection_filter.go @@ -170,20 +170,16 @@ func parseFilterFromExpression(f fexpr.ExprGroup, loc *time.Location) (filter *t return filter, nil } -func getTaskFiltersFromFilterString(filter string, filterTimezone string) (filters []*taskFilter, err error) { - - if filter == "" { - return - } - +// preprocessFilterString rewrites the human filter syntax (in / not in / like) +// into fexpr sigils and quotes bare values so fexpr.Parse accepts them. Shared +// by every entity that filters with the task grammar. +func preprocessFilterString(filter string) string { filter = strings.ReplaceAll(filter, " not in ", " "+string(fexpr.SignAnyNeq)+" ") filter = strings.ReplaceAll(filter, " in ", " ?= ") filter = strings.ReplaceAll(filter, " like ", " ~ ") - // Regex pattern to match filter expressions re := regexp.MustCompile(`(\w+)\s*(>=|<=|!=|~|\?=|\?!=|=|>|<)\s*([^&|()]+)`) - - filter = re.ReplaceAllStringFunc(filter, func(match string) string { + return re.ReplaceAllStringFunc(filter, func(match string) string { parts := re.FindStringSubmatch(match) if len(parts) != 4 { return match @@ -193,16 +189,24 @@ func getTaskFiltersFromFilterString(filter string, filterTimezone string) (filte comparator := parts[2] value := strings.TrimSpace(parts[3]) - // Check if the value is already quoted + // Already quoted — leave as-is if (strings.HasPrefix(value, "'") && strings.HasSuffix(value, "'")) || (strings.HasPrefix(value, "\"") && strings.HasSuffix(value, "\"")) { return field + " " + comparator + " " + value } - // Quote the value quotedValue := "'" + strings.ReplaceAll(value, "'", "\\'") + "'" return field + " " + comparator + " " + quotedValue }) +} + +func getTaskFiltersFromFilterString(filter string, filterTimezone string) (filters []*taskFilter, err error) { + + if filter == "" { + return + } + + filter = preprocessFilterString(filter) parsedFilter, err := fexpr.Parse(filter) if err != nil { From 42795518e9e5a6364e47d32af402c3e843db37c9 Mon Sep 17 00:00:00 2001 From: kolaente Date: Mon, 8 Jun 2026 15:10:55 +0200 Subject: [PATCH 005/214] feat(time-tracking): add the time_entries model --- pkg/db/db.go | 12 + pkg/models/error.go | 170 +++++++++++++++ pkg/models/time_tracking.go | 423 ++++++++++++++++++++++++++++++++++++ 3 files changed, 605 insertions(+) create mode 100644 pkg/models/time_tracking.go diff --git a/pkg/db/db.go b/pkg/db/db.go index 829dcd997..108ad4245 100644 --- a/pkg/db/db.go +++ b/pkg/db/db.go @@ -525,5 +525,17 @@ func CreateParadeDBIndexes() error { return fmt.Errorf("could not ensure paradedb project index: %w", err) } + // Create ParadeDB index for time_entries (comment search via MultiFieldSearch) + timeEntriesIndexSQL := `CREATE INDEX IF NOT EXISTS idx_time_entries_paradedb ON time_entries USING bm25 (id, comment) + WITH ( + key_field='id', + text_fields='{ + "comment": {"fast": true, "record": "freq"} + }' + )` + if _, err := x.Exec(timeEntriesIndexSQL); err != nil { + return fmt.Errorf("could not ensure paradedb time entry index: %w", err) + } + return nil } diff --git a/pkg/models/error.go b/pkg/models/error.go index bd15c2284..ae61ef305 100644 --- a/pkg/models/error.go +++ b/pkg/models/error.go @@ -2398,3 +2398,173 @@ func (err *ErrOAuthInvalidGrantType) HTTPError() web.HTTPError { Message: "The grant_type is not supported. Use 'authorization_code' or 'refresh_token'.", } } + +// ==================== +// Time Tracking Errors +// ==================== + +// ErrTimeEntryDoesNotExist represents an error where a time entry does not exist +type ErrTimeEntryDoesNotExist struct { + TimeEntryID int64 +} + +// IsErrTimeEntryDoesNotExist checks if an error is ErrTimeEntryDoesNotExist. +func IsErrTimeEntryDoesNotExist(err error) bool { + _, ok := err.(ErrTimeEntryDoesNotExist) + return ok +} + +func (err ErrTimeEntryDoesNotExist) Error() string { + return fmt.Sprintf("Time entry does not exist [TimeEntryID: %v]", err.TimeEntryID) +} + +// ErrCodeTimeEntryDoesNotExist holds the unique world-error code of this error +const ErrCodeTimeEntryDoesNotExist = 18001 + +// HTTPError holds the http error description +func (err ErrTimeEntryDoesNotExist) HTTPError() web.HTTPError { + return web.HTTPError{ + HTTPCode: http.StatusNotFound, + Code: ErrCodeTimeEntryDoesNotExist, + Message: "This time entry does not exist.", + } +} + +// ErrTimeEntryInvalidContainer represents an error where a time entry is attached +// to both a task and a project, or to neither (violating the XOR invariant). +type ErrTimeEntryInvalidContainer struct { + TaskID int64 + ProjectID int64 +} + +// IsErrTimeEntryInvalidContainer checks if an error is ErrTimeEntryInvalidContainer. +func IsErrTimeEntryInvalidContainer(err error) bool { + _, ok := err.(ErrTimeEntryInvalidContainer) + return ok +} + +func (err ErrTimeEntryInvalidContainer) Error() string { + return fmt.Sprintf("Time entry must be attached to exactly one of task or project [TaskID: %v, ProjectID: %v]", err.TaskID, err.ProjectID) +} + +// ErrCodeTimeEntryInvalidContainer holds the unique world-error code of this error +const ErrCodeTimeEntryInvalidContainer = 18002 + +// HTTPError holds the http error description +func (err ErrTimeEntryInvalidContainer) HTTPError() web.HTTPError { + return web.HTTPError{ + HTTPCode: http.StatusBadRequest, + Code: ErrCodeTimeEntryInvalidContainer, + Message: "A time entry must be attached to exactly one of a task or a project.", + } +} + +// ErrInvalidTimeEntryFilterField represents an error where a time entry filter references a non-filterable field +type ErrInvalidTimeEntryFilterField struct { + Field string +} + +// IsErrInvalidTimeEntryFilterField checks if an error is ErrInvalidTimeEntryFilterField. +func IsErrInvalidTimeEntryFilterField(err error) bool { + _, ok := err.(ErrInvalidTimeEntryFilterField) + return ok +} + +func (err ErrInvalidTimeEntryFilterField) Error() string { + return fmt.Sprintf("Time entry filter field is invalid [Field: %s]", err.Field) +} + +// ErrCodeInvalidTimeEntryFilterField holds the unique world-error code of this error +const ErrCodeInvalidTimeEntryFilterField = 18003 + +// HTTPError holds the http error description +func (err ErrInvalidTimeEntryFilterField) HTTPError() web.HTTPError { + return web.HTTPError{ + HTTPCode: http.StatusBadRequest, + Code: ErrCodeInvalidTimeEntryFilterField, + Message: fmt.Sprintf("The time entry filter field '%s' is invalid. Filterable fields are user_id, task_id, project_id, start_time and end_time.", err.Field), + } +} + +// ErrInvalidTimeEntryFilterValue represents an error where a time entry filter value cannot be parsed for its field +type ErrInvalidTimeEntryFilterValue struct { + Field string + Value string +} + +// IsErrInvalidTimeEntryFilterValue checks if an error is ErrInvalidTimeEntryFilterValue. +func IsErrInvalidTimeEntryFilterValue(err error) bool { + _, ok := err.(ErrInvalidTimeEntryFilterValue) + return ok +} + +func (err ErrInvalidTimeEntryFilterValue) Error() string { + return fmt.Sprintf("Time entry filter value is invalid [Field: %s, Value: %s]", err.Field, err.Value) +} + +// ErrCodeInvalidTimeEntryFilterValue holds the unique world-error code of this error +const ErrCodeInvalidTimeEntryFilterValue = 18004 + +// HTTPError holds the http error description +func (err ErrInvalidTimeEntryFilterValue) HTTPError() web.HTTPError { + return web.HTTPError{ + HTTPCode: http.StatusBadRequest, + Code: ErrCodeInvalidTimeEntryFilterValue, + Message: fmt.Sprintf("The value '%s' is not valid for the time entry filter field '%s'.", err.Value, err.Field), + } +} + +// ErrNoRunningTimer represents an error where a user has no running timer to act on +type ErrNoRunningTimer struct { + UserID int64 +} + +// IsErrNoRunningTimer checks if an error is ErrNoRunningTimer. +func IsErrNoRunningTimer(err error) bool { + _, ok := err.(ErrNoRunningTimer) + return ok +} + +func (err ErrNoRunningTimer) Error() string { + return fmt.Sprintf("No running timer [UserID: %d]", err.UserID) +} + +// ErrCodeNoRunningTimer holds the unique world-error code of this error +const ErrCodeNoRunningTimer = 18005 + +// HTTPError holds the http error description +func (err ErrNoRunningTimer) HTTPError() web.HTTPError { + return web.HTTPError{ + HTTPCode: http.StatusNotFound, + Code: ErrCodeNoRunningTimer, + Message: "You do not have a running timer.", + } +} + +// ErrTimeEntryAlreadyEnded represents an error where an update tries to clear the +// end time of an entry that has already ended (reopening it as a running timer). +type ErrTimeEntryAlreadyEnded struct { + TimeEntryID int64 +} + +// IsErrTimeEntryAlreadyEnded checks if an error is ErrTimeEntryAlreadyEnded. +func IsErrTimeEntryAlreadyEnded(err error) bool { + _, ok := err.(ErrTimeEntryAlreadyEnded) + return ok +} + +func (err ErrTimeEntryAlreadyEnded) Error() string { + return fmt.Sprintf("Time entry has already ended and cannot be reopened [TimeEntryID: %v]", err.TimeEntryID) +} + +// ErrCodeTimeEntryAlreadyEnded holds the unique world-error code of this error +const ErrCodeTimeEntryAlreadyEnded = 18006 + +// HTTPError holds the http error description +func (err ErrTimeEntryAlreadyEnded) HTTPError() web.HTTPError { + return web.HTTPError{ + HTTPCode: http.StatusBadRequest, + Code: ErrCodeTimeEntryAlreadyEnded, + Message: "A time entry that has already ended cannot be reopened into a running timer. Start a new timer instead.", + } +} diff --git a/pkg/models/time_tracking.go b/pkg/models/time_tracking.go new file mode 100644 index 000000000..bddfadb2b --- /dev/null +++ b/pkg/models/time_tracking.go @@ -0,0 +1,423 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package models + +import ( + "time" + + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/events" + "code.vikunja.io/api/pkg/license" + "code.vikunja.io/api/pkg/user" + "code.vikunja.io/api/pkg/web" + "xorm.io/builder" + "xorm.io/xorm" +) + +// TimeEntry is a single tracked time span attached to either a task or a +// project — exactly one of TaskID / ProjectID is set (XOR). A running live +// timer is just an entry whose EndTime is still null. +// +// v2-only: doc: tags are the schema's source of truth (no v1 swaggo), and it +// implements CRUDable + Permissions because the shared handler.Do* pipeline needs them. +type TimeEntry struct { + ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"timeentry" readOnly:"true" doc:"The unique, numeric id of this time entry."` + + UserID int64 `xorm:"bigint not null INDEX" json:"user_id" readOnly:"true" doc:"The id of the user who logged this time entry. Set by the server."` + + TaskID int64 `xorm:"bigint null INDEX" json:"task_id" doc:"The task this entry is attached to. Exactly one of task_id / project_id must be set."` + ProjectID int64 `xorm:"bigint null INDEX" json:"project_id" doc:"The project this entry is attached to directly. Exactly one of task_id / project_id must be set."` + + StartTime time.Time `xorm:"not null INDEX" json:"start_time" doc:"When the tracked time started."` + EndTime *time.Time `xorm:"null" json:"end_time" doc:"When the tracked time ended. Null means a live timer is still running."` + + Comment string `xorm:"text null" json:"comment" doc:"An optional comment describing the logged time."` + + Created time.Time `xorm:"created not null" json:"created" readOnly:"true" doc:"A timestamp when this time entry was created. You cannot change this value."` + Updated time.Time `xorm:"updated not null" json:"updated" readOnly:"true" doc:"A timestamp when this time entry was last updated. You cannot change this value."` + + // Filter-only fields (not persisted): set by the v2 list route, read by ReadAll. + Filter string `xorm:"-" json:"-"` + FilterTimezone string `xorm:"-" json:"-"` + + web.CRUDable `xorm:"-" json:"-"` + web.Permissions `xorm:"-" json:"-"` +} + +// TableName is time_entries, not the xorm-default time_entry. +func (*TimeEntry) TableName() string { + return "time_entries" +} + +// --- CRUDable --- + +func (te *TimeEntry) Create(s *xorm.Session, a web.Auth) (err error) { + te.UserID = a.GetID() + + // Starting a new running timer auto-stops the previous one; a completed + // manual entry (EndTime set) must leave the running timer alone. + if te.EndTime == nil { + if _, err = stopRunningTimerForUser(s, te.UserID); err != nil { + return err + } + } + + if te.StartTime.IsZero() { + te.StartTime = time.Now() + } + + if _, err = s.Insert(te); err != nil { + return err + } + + doer, err := user.GetFromAuth(a) + if err != nil { + return err + } + events.DispatchOnCommit(s, &TimeEntryCreatedEvent{TimeEntry: te, Doer: doer}) + return nil +} + +func (te *TimeEntry) ReadOne(_ *xorm.Session, _ web.Auth) (err error) { + // entry got already fetched in CanRead, nothing left to do here + return nil +} + +// stopRunningTimerForUser stops the user's active timer (end_time = now) and +// returns it, or nil if no timer is running. +func stopRunningTimerForUser(s *xorm.Session, userID int64) (*TimeEntry, error) { + running := &TimeEntry{} + exists, err := s.Where("user_id = ? AND end_time IS NULL", userID).Get(running) + if err != nil { + return nil, err + } + if !exists { + return nil, nil + } + if err := running.stop(s); err != nil { + return nil, err + } + + doer, err := user.GetUserByID(s, userID) + if err != nil { + return nil, err + } + events.DispatchOnCommit(s, &TimeEntryUpdatedEvent{TimeEntry: running, Doer: doer}) + return running, nil +} + +// StopRunningTimer stops the authenticated user's active timer and returns it, +// or ErrNoRunningTimer when none is running. The stop time is the server's now. +func StopRunningTimer(s *xorm.Session, a web.Auth) (*TimeEntry, error) { + // Link shares have no time tracking (mirrors the Can* methods). Their id is a + // share id, not a user id, so without this a share whose id collides with a + // user's would stop and read that user's running timer. + if _, isShare := a.(*LinkSharing); isShare { + return nil, ErrGenericForbidden{} + } + + running, err := stopRunningTimerForUser(s, a.GetID()) + if err != nil { + return nil, err + } + if running == nil { + return nil, ErrNoRunningTimer{UserID: a.GetID()} + } + return running, nil +} + +// readableTimeEntriesCond restricts a query to entries the auth can read: a +// standalone entry on an accessible project, or one on a task in such a project. +func readableTimeEntriesCond(a web.Auth) builder.Cond { + return entriesForProjectCond(accessibleProjectIDsSubquery(a, "project_id")) +} + +func (te *TimeEntry) ReadAll(s *xorm.Session, a web.Auth, search string, page int, perPage int) (result any, resultCount int, numberOfTotalItems int64, err error) { + // Link shares have no time-tracking access (mirrors the Can* methods); + // DoReadAll skips the permission check, so it must be guarded here too. + if _, isShareAuth := a.(*LinkSharing); isShareAuth { + return []*TimeEntry{}, 0, 0, nil + } + + cond := readableTimeEntriesCond(a) + if te.TaskID > 0 { + cond = cond.And(builder.Eq{"task_id": te.TaskID}) + } + if te.ProjectID > 0 { + cond = cond.And(entriesForProjectCond(builder.Eq{"project_id": te.ProjectID})) + } + + filterCond, err := timeEntryFilterCond(te.Filter, te.FilterTimezone) + if err != nil { + return nil, 0, 0, err + } + if filterCond != nil { + cond = cond.And(filterCond) + } + + if search != "" { + cond = cond.And(db.MultiFieldSearch([]string{"comment"}, search)) + } + + total, err := s.Where(cond). + Count(&TimeEntry{}) + if err != nil { + return nil, 0, 0, err + } + + entries := []*TimeEntry{} + err = s.Where(cond). + OrderBy("start_time ASC"). + Limit(getLimitFromPageIndex(page, perPage)). + Find(&entries) + return entries, len(entries), total, err +} + +func (te *TimeEntry) Update(s *xorm.Session, a web.Auth) (err error) { + // A completed entry can't be reopened into a running timer via update — that + // would sidestep Create's single-active-timer rule; start a new one instead. + existing, err := getTimeEntryByID(s, te.ID) + if err != nil { + return err + } + if existing.EndTime != nil && te.EndTime == nil { + return ErrTimeEntryAlreadyEnded{TimeEntryID: te.ID} + } + + // task_id / project_id are listed so a reassignment (and the zero value of + // the side being cleared) is written; the XOR was validated in CanUpdate. + _, err = s. + Where("id = ?", te.ID). + Cols("task_id", "project_id", "start_time", "end_time", "comment"). + Update(te) + if err != nil { + return err + } + + // reload: Update wrote only the editable columns + updated, err := getTimeEntryByID(s, te.ID) + if err != nil { + return err + } + *te = *updated + + doer, err := user.GetFromAuth(a) + if err != nil { + return err + } + events.DispatchOnCommit(s, &TimeEntryUpdatedEvent{TimeEntry: te, Doer: doer}) + return nil +} + +func (te *TimeEntry) Delete(s *xorm.Session, a web.Auth) (err error) { + entry, err := getTimeEntryByID(s, te.ID) + if err != nil { + return err + } + if _, err = s.Where("id = ?", te.ID).Delete(&TimeEntry{}); err != nil { + return err + } + + doer, err := user.GetFromAuth(a) + if err != nil { + return err + } + events.DispatchOnCommit(s, &TimeEntryDeletedEvent{TimeEntry: entry, Doer: doer}) + return nil +} + +func getTimeEntryByID(s *xorm.Session, id int64) (*TimeEntry, error) { + entry := &TimeEntry{} + exists, err := s.Where("id = ?", id).Get(entry) + if err != nil { + return nil, err + } + if !exists { + return nil, ErrTimeEntryDoesNotExist{TimeEntryID: id} + } + return entry, nil +} + +func (te *TimeEntry) stop(s *xorm.Session) (err error) { + now := time.Now() + te.EndTime = &now + _, err = s.ID(te.ID).Update(te) + return err +} + +// --- Permissions --- + +// Returns the loaded entry rather than mutating te, so Update keeps its payload. +func (te *TimeEntry) canDoTimeEntry(s *xorm.Session, a web.Auth, fetch bool) (*TimeEntry, bool, int, error) { + entry := &TimeEntry{TaskID: te.TaskID, ProjectID: te.ProjectID} + if fetch { + var err error + entry, err = getTimeEntryByID(s, te.ID) + if err != nil { + return nil, false, -1, err + } + } + + switch { + case entry.TaskID != 0: + task, err := GetTaskByIDSimple(s, entry.TaskID) + if err != nil { + return entry, false, -1, err + } + can, maxPerm, err := task.CanRead(s, a) + return entry, can, maxPerm, err + case entry.ProjectID != 0: + project, _, err := getProjectSimple(s, builder.Eq{"id": entry.ProjectID}) + if err != nil { + return entry, false, -1, err + } + can, maxPerm, err := project.CanRead(s, a) + return entry, can, maxPerm, err + default: + return entry, false, 0, nil + } +} + +func (te *TimeEntry) CanRead(s *xorm.Session, a web.Auth) (bool, int, error) { + if _, isShareAuth := a.(*LinkSharing); isShareAuth { + return false, 0, nil + } + + entry, can, maxPerm, err := te.canDoTimeEntry(s, a, true) + if err != nil { + return false, maxPerm, err + } + *te = *entry // ReadOne is a no-op; populate te here + return can, maxPerm, nil +} + +// validateContainer enforces the XOR invariant: exactly one of task or project. +func (te *TimeEntry) validateContainer() error { + if (te.TaskID == 0) == (te.ProjectID == 0) { + return ErrTimeEntryInvalidContainer{TaskID: te.TaskID, ProjectID: te.ProjectID} + } + return nil +} + +func (te *TimeEntry) CanCreate(s *xorm.Session, a web.Auth) (bool, error) { + if _, isShareAuth := a.(*LinkSharing); isShareAuth { + return false, nil + } + + if err := te.validateContainer(); err != nil { + return false, err + } + + _, can, _, err := te.canDoTimeEntry(s, a, false) + return can, err +} + +// CanUpdate allows the author to edit their entry, including moving it between +// task / project: on top of the author check it validates the (possibly new) +// container (XOR) and requires read access to it, mirroring create. +func (te *TimeEntry) CanUpdate(s *xorm.Session, a web.Auth) (bool, error) { + if _, isShareAuth := a.(*LinkSharing); isShareAuth { + return false, nil + } + + existing, err := getTimeEntryByID(s, te.ID) + if err != nil { + return false, err + } + if existing.UserID != a.GetID() { + return false, nil + } + + // A request that omits the container keeps the existing one — an entry + // always has exactly one, so "clearing" it is never valid. + if te.TaskID == 0 && te.ProjectID == 0 { + te.TaskID = existing.TaskID + te.ProjectID = existing.ProjectID + } + if err := te.validateContainer(); err != nil { + return false, err + } + + _, canReadContainer, _, err := te.canDoTimeEntry(s, a, false) + return canReadContainer, err +} + +func (te *TimeEntry) CanDelete(s *xorm.Session, a web.Auth) (bool, error) { + return te.canModify(s, a) +} + +// canModify gates delete: read access to the container plus being the author. +func (te *TimeEntry) canModify(s *xorm.Session, a web.Auth) (bool, error) { + if _, isShareAuth := a.(*LinkSharing); isShareAuth { + return false, nil + } + + entry, canRead, _, err := te.canDoTimeEntry(s, a, true) + if err != nil { + return false, err + } + if !canRead { + return false, nil + } + return entry.UserID == a.GetID(), nil +} + +// addTimeEntriesCountToTasks attaches each task's time-entry count for the +// `time_entries_count` expand. Mirrors addCommentCountToTasks, but follows the +// same gates as the time-entry endpoints: the count is left unset (absent) for +// link shares or when the feature is unlicensed, so it can't leak that way. +func addTimeEntriesCountToTasks(s *xorm.Session, a web.Auth, taskIDs []int64, taskMap map[int64]*Task) error { + if _, isShare := a.(*LinkSharing); isShare { + return nil + } + if !license.IsFeatureEnabled(license.FeatureTimeTracking) { + return nil + } + if len(taskIDs) == 0 { + return nil + } + + zero := int64(0) + for _, taskID := range taskIDs { + if task, ok := taskMap[taskID]; ok { + task.TimeEntriesCount = &zero + } + } + + type timeEntriesCount struct { + TaskID int64 `xorm:"task_id"` + Count int64 `xorm:"count"` + } + + counts := []timeEntriesCount{} + if err := s. + Select("task_id, COUNT(*) as count"). + Where(builder.In("task_id", taskIDs)). + GroupBy("task_id"). + Table("time_entries"). + Find(&counts); err != nil { + return err + } + + for _, c := range counts { + if task, ok := taskMap[c.TaskID]; ok { + task.TimeEntriesCount = &c.Count + } + } + + return nil +} From 4bd6a6c4f736cebea7c4e8dea4392b7b5f1d2574 Mon Sep 17 00:00:00 2001 From: kolaente Date: Mon, 8 Jun 2026 15:10:55 +0200 Subject: [PATCH 006/214] feat(time-tracking): filter time entries with the task DSL --- pkg/models/time_tracking_filter.go | 204 +++++++++++++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 pkg/models/time_tracking_filter.go diff --git a/pkg/models/time_tracking_filter.go b/pkg/models/time_tracking_filter.go new file mode 100644 index 000000000..ec4c7b68e --- /dev/null +++ b/pkg/models/time_tracking_filter.go @@ -0,0 +1,204 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package models + +import ( + "strconv" + "strings" + "time" + + "code.vikunja.io/api/pkg/config" + + "github.com/ganigeorgiev/fexpr" + "github.com/jszwedko/go-datemath" + "xorm.io/builder" +) + +// entriesForProjectCond matches time entries belonging to a project given a +// predicate over a project_id column: standalone entries whose own project_id +// matches, plus task-attached entries whose task currently lives in a matching +// project. Tasks move between projects, so the project is resolved via the task +// at query time rather than denormalized. Used for both permission scoping and +// the project_id filter. +func entriesForProjectCond(projectIDCond builder.Cond) builder.Cond { + return builder.Or( + projectIDCond, + builder.In("task_id", + builder.Select("id").From("tasks").Where(projectIDCond), + ), + ) +} + +// timeEntryFilterCond parses a task-style filter string into a condition over +// the time_entries table, or nil for an empty filter. Filterable fields: +// user_id, task_id, project_id (ints / in-lists), start_time, end_time (dates, +// datemath, or the literal null for running timers). comment is deliberately +// not filterable — text matching belongs to search. +func timeEntryFilterCond(filter, filterTimezone string) (builder.Cond, error) { + if filter == "" { + return nil, nil + } + + parsed, err := fexpr.Parse(preprocessFilterString(filter)) + if err != nil { + return nil, &ErrInvalidFilterExpression{Expression: filter, ExpressionError: err} + } + + loc := config.GetTimeZone() + if filterTimezone != "" { + loc, err = time.LoadLocation(filterTimezone) + if err != nil { + return nil, &ErrInvalidTimezone{Name: filterTimezone, LoadError: err} + } + } + + return buildTimeEntryFilterCond(parsed, loc) +} + +func buildTimeEntryFilterCond(groups []fexpr.ExprGroup, loc *time.Location) (builder.Cond, error) { + conds := make([]builder.Cond, 0, len(groups)) + joins := make([]taskFilterConcatinator, 0, len(groups)) + + for _, g := range groups { + join := filterConcatAnd + if g.Join == fexpr.JoinOr { + join = filterConcatOr + } + + var ( + cond builder.Cond + err error + ) + switch item := g.Item.(type) { + case []fexpr.ExprGroup: // a parenthesized sub-expression + cond, err = buildTimeEntryFilterCond(item, loc) + case fexpr.Expr: + var comparator taskFilterComparator + comparator, err = getFilterComparatorFromOp(item.Op) + if err == nil { + cond, err = resolveTimeEntryFilter(item.Left.Literal, comparator, item.Right.Literal, loc) + } + } + if err != nil { + return nil, err + } + conds = append(conds, cond) + joins = append(joins, join) + } + + if len(conds) == 0 { + return nil, nil + } + result := conds[0] + for i := 1; i < len(conds); i++ { + if joins[i] == filterConcatOr { + result = builder.Or(result, conds[i]) + continue + } + result = builder.And(result, conds[i]) + } + return result, nil +} + +func resolveTimeEntryFilter(field string, comparator taskFilterComparator, raw string, loc *time.Location) (builder.Cond, error) { + switch field { + case "user_id", "task_id": + value, err := timeEntryIntFilterValue(raw, comparator) + if err != nil { + return nil, ErrInvalidTimeEntryFilterValue{Field: field, Value: raw} + } + return getFilterCond(&taskFilter{field: field, value: value, comparator: comparator, isNumeric: true}, false) + + case "project", "project_id": + value, err := timeEntryIntFilterValue(raw, comparator) + if err != nil { + return nil, ErrInvalidTimeEntryFilterValue{Field: "project_id", Value: raw} + } + // Build membership positively (standalone-in-project OR task-in-project) + // and negate the whole set for != / not in. Negating project_id alone would + // wrongly match task-attached entries, whose own project_id is 0. + positive, negate := comparator, false + if comparator == taskFilterComparatorNotEquals { + positive, negate = taskFilterComparatorEquals, true + } + if comparator == taskFilterComparatorNotIn { + positive, negate = taskFilterComparatorIn, true + } + inner, err := getFilterCond(&taskFilter{field: "project_id", value: value, comparator: positive, isNumeric: true}, false) + if err != nil { + return nil, err + } + cond := entriesForProjectCond(inner) + if negate { + cond = builder.Not{cond} + } + return cond, nil + + case "start_time", "end_time": + if raw == "null" { + return nullTimeFilterCond(field, comparator) + } + value, err := timeEntryTimeFilterValue(raw, loc) + if err != nil { + return nil, ErrInvalidTimeEntryFilterValue{Field: field, Value: raw} + } + return getFilterCond(&taskFilter{field: field, value: value, comparator: comparator}, false) + + default: + return nil, ErrInvalidTimeEntryFilterField{Field: field} + } +} + +// nullTimeFilterCond handles `end_time = null` (running timers) and its negation. +func nullTimeFilterCond(field string, comparator taskFilterComparator) (builder.Cond, error) { + if comparator == taskFilterComparatorEquals { + return &builder.IsNull{field}, nil + } + if comparator == taskFilterComparatorNotEquals { + return &builder.NotNull{field}, nil + } + return nil, ErrInvalidTimeEntryFilterValue{Field: field, Value: "null"} +} + +func timeEntryIntFilterValue(raw string, comparator taskFilterComparator) (any, error) { + if comparator == taskFilterComparatorIn || comparator == taskFilterComparatorNotIn { + parts := strings.Split(raw, ",") + values := make([]int64, 0, len(parts)) + for _, part := range parts { + v, err := strconv.ParseInt(strings.TrimSpace(part), 10, 64) + if err != nil { + return nil, err + } + values = append(values, v) + } + return values, nil + } + return strconv.ParseInt(strings.TrimSpace(raw), 10, 64) +} + +// timeEntryTimeFilterValue mirrors the task filter's date handling: datemath +// (now, now-7d) first, then explicit date formats. +func timeEntryTimeFilterValue(raw string, loc *time.Location) (time.Time, error) { + if loc == nil { + loc = config.GetTimeZone() + } + if expr, err := safeDatemathParse(raw); err == nil { + t := expr.Time(datemath.WithLocation(loc)).In(config.GetTimeZone()) + return adjustDateForMysql(t), nil + } + return parseTimeFromUserInput(raw, loc) +} From 9454cd3ec55daace7f580e98ef484932d09af087 Mon Sep 17 00:00:00 2001 From: kolaente Date: Mon, 8 Jun 2026 15:10:55 +0200 Subject: [PATCH 007/214] feat(time-tracking): expose time entries on the v2 API --- pkg/routes/api/v2/time_entries.go | 285 ++++++++++++++++++++++++++++++ 1 file changed, 285 insertions(+) create mode 100644 pkg/routes/api/v2/time_entries.go diff --git a/pkg/routes/api/v2/time_entries.go b/pkg/routes/api/v2/time_entries.go new file mode 100644 index 000000000..3500677f7 --- /dev/null +++ b/pkg/routes/api/v2/time_entries.go @@ -0,0 +1,285 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "fmt" + "net/http" + + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/events" + "code.vikunja.io/api/pkg/license" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/web/handler" + + "github.com/danielgtaylor/huma/v2" + "github.com/danielgtaylor/huma/v2/conditional" +) + +// timeTrackingGate is Huma operation middleware that 404s a time-tracking op when the license +// feature is off. It's a middleware because license state can change while the instance is running. +func timeTrackingGate(api huma.API) func(huma.Context, func(huma.Context)) { + return func(ctx huma.Context, next func(huma.Context)) { + if !license.IsFeatureEnabled(license.FeatureTimeTracking) { + _ = huma.WriteErr(api, ctx, http.StatusNotFound, "Not Found") + return + } + next(ctx) + } +} + +func registerGated[I, O any](api huma.API, op huma.Operation, handler func(context.Context, *I) (*O, error)) { + op.Middlewares = append(op.Middlewares, timeTrackingGate(api)) + Register(api, op, handler) +} + +type timeEntryListBody struct { + Body Paginated[*models.TimeEntry] +} + +// RegisterTimeEntryRoutes wires the time-entry CRUD surface onto the Huma API. +func RegisterTimeEntryRoutes(api huma.API) { + tags := []string{"time-entries"} + + registerGated(api, huma.Operation{ + OperationID: "time-entries-list", + Summary: "List time entries", + Description: "Returns the time entries the authenticated user can see, paginated. Filterable by date range, project, task and user.", + Method: http.MethodGet, + Path: "/time-entries", + Tags: tags, + }, timeEntriesList) + + registerGated(api, huma.Operation{ + OperationID: "time-entries-read", + Summary: "Get a time entry", + Description: "Returns a single time entry. Sends an ETag; pass it as If-None-Match on a later read to get a 304 Not Modified.", + Method: http.MethodGet, + Path: "/time-entries/{id}", + Tags: tags, + }, timeEntriesRead) + + registerGated(api, huma.Operation{ + OperationID: "time-entries-create", + Summary: "Create a time entry", + Description: "Logs a manual time entry for the authenticated user. Exactly one of task_id / project_id must be set.", + Method: http.MethodPost, + Path: "/time-entries", + Tags: tags, + }, timeEntriesCreate) + + registerGated(api, huma.Operation{ + OperationID: "time-entries-update", + Summary: "Update a time entry", + Description: "Updates a time entry. Only the author may update it. The entry can be moved between a task and a project — exactly one of task_id / project_id must be set, and you need read access to the new one. PUT replaces all editable fields; use PATCH for a partial update.", + Method: http.MethodPut, + Path: "/time-entries/{id}", + Tags: tags, + }, timeEntriesUpdate) + + registerGated(api, huma.Operation{ + OperationID: "time-entries-delete", + Summary: "Delete a time entry", + Description: "Deletes a time entry. Only the author may delete it. If it is the running timer, deleting it removes that timer.", + Method: http.MethodDelete, + Path: "/time-entries/{id}", + Tags: tags, + }, timeEntriesDelete) + + registerGated(api, huma.Operation{ + OperationID: "task-time-entries-list", + Summary: "List a task's time entries", + Description: "Returns the time entries logged against the given task, across all users, paginated. Scoped to what you can read: an inaccessible or unknown task yields an empty list, not an error.", + Method: http.MethodGet, + Path: "/tasks/{task_id}/time-entries", + Tags: tags, + }, taskTimeEntriesList) + + registerGated(api, huma.Operation{ + OperationID: "project-time-entries-list", + Summary: "List a project's time entries", + Description: "Returns the time entries for the given project — both standalone project entries and entries on tasks currently in the project — paginated. Scoped to what you can read: an inaccessible or unknown project yields an empty list, not an error.", + Method: http.MethodGet, + Path: "/projects/{project_id}/time-entries", + Tags: tags, + }, projectTimeEntriesList) + + registerGated(api, huma.Operation{ + OperationID: "time-entries-timer-stop", + Summary: "Stop the running timer", + Description: "Stops the authenticated user's running timer, setting its end time to the server's current time, and returns the stopped entry. Returns 404 when no timer is running. Starting a timer and editing entries go through the regular create/update endpoints.", + Method: http.MethodPost, + Path: "/time-entries/timer/stop", + // Override the wrapper's POST→201: this stops an existing entry, it creates nothing. + DefaultStatus: http.StatusOK, + Tags: tags, + }, timeEntriesTimerStop) +} + +func init() { AddRouteRegistrar(RegisterTimeEntryRoutes) } + +// timeEntriesTimerStop is a custom action scoped to the caller: it stops their +// own running timer, so it owns its session and needs no resource permission +// beyond authentication. +func timeEntriesTimerStop(ctx context.Context, _ *struct{}) (*singleBody[models.TimeEntry], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + + s := db.NewSession() + defer s.Close() + + entry, err := models.StopRunningTimer(s, a) + if err != nil { + _ = s.Rollback() + events.CleanupPending(s) + return nil, translateDomainError(err) + } + if err := s.Commit(); err != nil { + events.CleanupPending(s) + return nil, translateDomainError(err) + } + events.DispatchPending(s) + return &singleBody[models.TimeEntry]{Body: entry}, nil +} + +func timeEntriesList(ctx context.Context, in *struct { + ListParams + Filter string `query:"filter" doc:"Filter entries with the task filter syntax over user_id, task_id, project_id, start_time and end_time — e.g. \"project_id = 5 && start_time > now-7d\". Use end_time = null to match running timers."` + FilterTimezone string `query:"filter_timezone" doc:"IANA timezone name used to resolve relative dates (now, now-7d) in the filter, e.g. Europe/Berlin."` +}) (*timeEntryListBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + m := &models.TimeEntry{ + Filter: in.Filter, + FilterTimezone: in.FilterTimezone, + } + result, _, total, err := handler.DoReadAll(ctx, m, a, in.Q, in.Page, in.PerPage) + if err != nil { + return nil, translateDomainError(err) + } + return timeEntriesListResponse(result, total, in.Page, in.PerPage) +} + +type timeEntryReadBody struct { + models.TimeEntry + MaxPermission models.Permission `json:"max_permission" readOnly:"true" doc:"The maximum permission the requesting user has on this time entry (0=read, 1=read/write, 2=admin)."` +} + +func timeEntriesRead(ctx context.Context, in *struct { + ID int64 `path:"id"` + conditional.Params +}) (*singleReadBody[timeEntryReadBody], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + entry := &models.TimeEntry{ID: in.ID} + maxPermission, err := handler.DoReadOne(ctx, entry, a) + if err != nil { + return nil, translateDomainError(err) + } + body := &timeEntryReadBody{TimeEntry: *entry, MaxPermission: models.Permission(maxPermission)} + return conditionalReadResponse(&in.Params, body, entry.Updated, maxPermission) +} + +func timeEntriesCreate(ctx context.Context, in *struct { + Body models.TimeEntry +}) (*singleBody[models.TimeEntry], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + if err := handler.DoCreate(ctx, &in.Body, a); err != nil { + return nil, translateDomainError(err) + } + return &singleBody[models.TimeEntry]{Body: &in.Body}, nil +} + +// Body matches the read shape so AutoPatch's GET→PUT echo of max_permission validates. +func timeEntriesUpdate(ctx context.Context, in *struct { + ID int64 `path:"id"` + Body timeEntryReadBody +}) (*singleBody[models.TimeEntry], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + entry := &in.Body.TimeEntry + entry.ID = in.ID // URL wins over body + if err := handler.DoUpdate(ctx, entry, a); err != nil { + return nil, translateDomainError(err) + } + return &singleBody[models.TimeEntry]{Body: entry}, nil +} + +func timeEntriesDelete(ctx context.Context, in *struct { + ID int64 `path:"id"` +}) (*emptyBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + if err := handler.DoDelete(ctx, &models.TimeEntry{ID: in.ID}, a); err != nil { + return nil, translateDomainError(err) + } + return &emptyBody{}, nil +} + +func taskTimeEntriesList(ctx context.Context, in *struct { + TaskID int64 `path:"task_id"` + ListParams +}) (*timeEntryListBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + result, _, total, err := handler.DoReadAll(ctx, &models.TimeEntry{TaskID: in.TaskID}, a, in.Q, in.Page, in.PerPage) + if err != nil { + return nil, translateDomainError(err) + } + return timeEntriesListResponse(result, total, in.Page, in.PerPage) +} + +func projectTimeEntriesList(ctx context.Context, in *struct { + ProjectID int64 `path:"project_id"` + ListParams +}) (*timeEntryListBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + result, _, total, err := handler.DoReadAll(ctx, &models.TimeEntry{ProjectID: in.ProjectID}, a, in.Q, in.Page, in.PerPage) + if err != nil { + return nil, translateDomainError(err) + } + return timeEntriesListResponse(result, total, in.Page, in.PerPage) +} + +// timeEntriesListResponse turns the any-typed DoReadAll result into the list +// envelope, hard-failing on a type mismatch (the generic-any silent-empty trap). +func timeEntriesListResponse(result any, total int64, page, perPage int) (*timeEntryListBody, error) { + items, ok := result.([]*models.TimeEntry) + if !ok { + return nil, fmt.Errorf("timeEntries.ReadAll returned unexpected type %T (expected []*models.TimeEntry)", result) + } + return &timeEntryListBody{Body: NewPaginated(items, total, page, perPage)}, nil +} From 0c5a0a99ec86182540d73751249d2a1f3544de71 Mon Sep 17 00:00:00 2001 From: kolaente Date: Mon, 8 Jun 2026 15:11:06 +0200 Subject: [PATCH 008/214] feat(time-tracking): dispatch time-entry events --- pkg/models/events.go | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/pkg/models/events.go b/pkg/models/events.go index 937524a1a..fca768388 100644 --- a/pkg/models/events.go +++ b/pkg/models/events.go @@ -362,3 +362,36 @@ type WebhookDeliveryEvent struct { func (w *WebhookDeliveryEvent) Name() string { return "webhook.delivery" } + +// TimeEntryCreatedEvent represents a time entry being created +type TimeEntryCreatedEvent struct { + TimeEntry *TimeEntry `json:"time_entry"` + Doer *user.User `json:"doer"` +} + +// Name defines the name for TimeEntryCreatedEvent +func (e *TimeEntryCreatedEvent) Name() string { + return "time-entry.created" +} + +// TimeEntryUpdatedEvent represents a time entry being updated (including a timer being stopped) +type TimeEntryUpdatedEvent struct { + TimeEntry *TimeEntry `json:"time_entry"` + Doer *user.User `json:"doer"` +} + +// Name defines the name for TimeEntryUpdatedEvent +func (e *TimeEntryUpdatedEvent) Name() string { + return "time-entry.updated" +} + +// TimeEntryDeletedEvent represents a time entry being deleted +type TimeEntryDeletedEvent struct { + TimeEntry *TimeEntry `json:"time_entry"` + Doer *user.User `json:"doer"` +} + +// Name defines the name for TimeEntryDeletedEvent +func (e *TimeEntryDeletedEvent) Name() string { + return "time-entry.deleted" +} From e197b1912f88122f91f0185b62458636fff9d47c Mon Sep 17 00:00:00 2001 From: kolaente Date: Mon, 8 Jun 2026 15:11:07 +0200 Subject: [PATCH 009/214] feat(time-tracking): count tracked time entries per task --- pkg/models/task_collection.go | 5 ++++- pkg/models/tasks.go | 8 ++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/pkg/models/task_collection.go b/pkg/models/task_collection.go index 22f048564..833cc7851 100644 --- a/pkg/models/task_collection.go +++ b/pkg/models/task_collection.go @@ -69,6 +69,7 @@ const TaskCollectionExpandBuckets TaskCollectionExpandable = `buckets` const TaskCollectionExpandReactions TaskCollectionExpandable = `reactions` const TaskCollectionExpandComments TaskCollectionExpandable = `comments` const TaskCollectionExpandCommentCount TaskCollectionExpandable = `comment_count` +const TaskCollectionExpandTimeEntriesCount TaskCollectionExpandable = `time_entries_count` const TaskCollectionExpandIsUnread TaskCollectionExpandable = `is_unread` // Validate validates if the TaskCollectionExpandable value is valid. @@ -84,11 +85,13 @@ func (t TaskCollectionExpandable) Validate() error { return nil case TaskCollectionExpandCommentCount: return nil + case TaskCollectionExpandTimeEntriesCount: + return nil case TaskCollectionExpandIsUnread: return nil } - return InvalidFieldErrorWithMessage([]string{"expand"}, "Expand must be one of the following values: subtasks, buckets, reactions, comments, comment_count, is_unread") + return InvalidFieldErrorWithMessage([]string{"expand"}, "Expand must be one of the following values: subtasks, buckets, reactions, comments, comment_count, time_entries_count, is_unread") } func validateTaskField(fieldName string) error { diff --git a/pkg/models/tasks.go b/pkg/models/tasks.go index 236bc7bca..c11854b93 100644 --- a/pkg/models/tasks.go +++ b/pkg/models/tasks.go @@ -140,6 +140,9 @@ type Task struct { // Comment count of this task. Only present when fetching tasks with the `expand` parameter set to `comment_count`. CommentCount *int64 `xorm:"-" json:"comment_count,omitempty"` + // Time entry count of this task. Only present when fetching tasks with the `expand` parameter set to `time_entries_count`. + TimeEntriesCount *int64 `xorm:"-" json:"time_entries_count,omitempty"` + // Behaves exactly the same as with the TaskCollection.Expand parameter Expand []TaskCollectionExpandable `xorm:"-" json:"-" query:"expand"` @@ -767,6 +770,11 @@ func addMoreInfoToTasks(s *xorm.Session, taskMap map[int64]*Task, a web.Auth, vi if err != nil { return err } + case TaskCollectionExpandTimeEntriesCount: + err = addTimeEntriesCountToTasks(s, a, taskIDs, taskMap) + if err != nil { + return err + } case TaskCollectionExpandIsUnread: err = addIsUnreadToTasks(s, taskIDs, taskMap, a) if err != nil { From cf22f089749b37ada449926c1572a37953561bf5 Mon Sep 17 00:00:00 2001 From: kolaente Date: Mon, 8 Jun 2026 15:11:07 +0200 Subject: [PATCH 010/214] feat(time-tracking): broadcast timer changes over websocket --- pkg/websocket/listener.go | 42 ++++++++ pkg/websocket/main_test.go | 4 + pkg/websocket/time_entry_listener_test.go | 113 ++++++++++++++++++++++ 3 files changed, 159 insertions(+) create mode 100644 pkg/websocket/time_entry_listener_test.go diff --git a/pkg/websocket/listener.go b/pkg/websocket/listener.go index 3250d1c45..e46759a71 100644 --- a/pkg/websocket/listener.go +++ b/pkg/websocket/listener.go @@ -21,7 +21,9 @@ import ( "code.vikunja.io/api/pkg/db" "code.vikunja.io/api/pkg/events" + "code.vikunja.io/api/pkg/license" "code.vikunja.io/api/pkg/log" + "code.vikunja.io/api/pkg/models" "code.vikunja.io/api/pkg/notifications" "github.com/ThreeDotsLabs/watermill/message" @@ -67,10 +69,50 @@ func (n *NotificationListener) Handle(msg *message.Message) error { return nil } +// TimeEntryListener pushes a user's own timer changes to their WebSocket +// connections. wsEvent is "timer.created", "timer.updated" or "timer.deleted"; +// the payload is the full entry, so the running-elsewhere badge reads end_time +// to know whether a timer is active (and the id to drop a deleted one). Not +// emitted on unlicensed instances. +type TimeEntryListener struct { + wsEvent string +} + +func (l *TimeEntryListener) Name() string { return "websocket.push." + l.wsEvent } + +func (l *TimeEntryListener) Handle(msg *message.Message) error { + if !license.IsFeatureEnabled(license.FeatureTimeTracking) { + return nil + } + + // All TimeEntry events share the {time_entry, doer} shape; only the entry is needed. + var event struct { + TimeEntry *models.TimeEntry `json:"time_entry"` + } + if err := json.Unmarshal(msg.Payload, &event); err != nil { + return err + } + if event.TimeEntry == nil { + return nil + } + + hub := GetHub() + if hub == nil { + log.Warningf("WebSocket: hub not initialized, skipping timer push") + return nil + } + + hub.PublishForUser(event.TimeEntry.UserID, l.wsEvent, event.TimeEntry) + return nil +} + // RegisterListeners registers WebSocket event listeners. func RegisterListeners() { events.RegisterListener( (¬ifications.NotificationCreatedEvent{}).Name(), &NotificationListener{}, ) + events.RegisterListener((&models.TimeEntryCreatedEvent{}).Name(), &TimeEntryListener{wsEvent: "timer.created"}) + events.RegisterListener((&models.TimeEntryUpdatedEvent{}).Name(), &TimeEntryListener{wsEvent: "timer.updated"}) + events.RegisterListener((&models.TimeEntryDeletedEvent{}).Name(), &TimeEntryListener{wsEvent: "timer.deleted"}) } diff --git a/pkg/websocket/main_test.go b/pkg/websocket/main_test.go index 7740dc0e0..21243069b 100644 --- a/pkg/websocket/main_test.go +++ b/pkg/websocket/main_test.go @@ -20,10 +20,14 @@ import ( "os" "testing" + "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/log" + "code.vikunja.io/api/pkg/modules/keyvalue" ) func TestMain(m *testing.M) { log.InitLogger() + config.InitDefaultConfig() + keyvalue.InitStorage() // license.SetForTests persists state through keyvalue os.Exit(m.Run()) } diff --git a/pkg/websocket/time_entry_listener_test.go b/pkg/websocket/time_entry_listener_test.go new file mode 100644 index 000000000..8f67df8b9 --- /dev/null +++ b/pkg/websocket/time_entry_listener_test.go @@ -0,0 +1,113 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package websocket + +import ( + "testing" + + "code.vikunja.io/api/pkg/events" + "code.vikunja.io/api/pkg/license" + "code.vikunja.io/api/pkg/models" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func timerConn(userID int64) *Connection { + return &Connection{ + userID: userID, + subscriptions: map[string]bool{"timer.created": true, "timer.updated": true, "timer.deleted": true}, + send: make(chan OutgoingMessage, 16), + } +} + +func TestTimeEntryListener(t *testing.T) { + t.Run("a create pushes timer.created with the entry to its owner", func(t *testing.T) { + InitHub() + conn := timerConn(1) + GetHub().Register(conn) + license.SetForTests([]license.Feature{license.FeatureTimeTracking}) + defer license.ResetForTests() + + ev := &models.TimeEntryCreatedEvent{TimeEntry: &models.TimeEntry{ID: 4, UserID: 1}} + events.TestListener(t, ev, &TimeEntryListener{wsEvent: "timer.created"}) + + require.Len(t, conn.send, 1) + msg := <-conn.send + assert.Equal(t, "timer.created", msg.Event) + te, ok := msg.Data.(*models.TimeEntry) + require.True(t, ok, "payload must be the time entry itself") + assert.Equal(t, int64(4), te.ID) + }) + + t.Run("an update pushes timer.updated", func(t *testing.T) { + InitHub() + conn := timerConn(1) + GetHub().Register(conn) + license.SetForTests([]license.Feature{license.FeatureTimeTracking}) + defer license.ResetForTests() + + ev := &models.TimeEntryUpdatedEvent{TimeEntry: &models.TimeEntry{ID: 4, UserID: 1}} + events.TestListener(t, ev, &TimeEntryListener{wsEvent: "timer.updated"}) + + require.Len(t, conn.send, 1) + assert.Equal(t, "timer.updated", (<-conn.send).Event) + }) + + t.Run("a delete pushes timer.deleted so other tabs drop it", func(t *testing.T) { + InitHub() + conn := timerConn(1) + GetHub().Register(conn) + license.SetForTests([]license.Feature{license.FeatureTimeTracking}) + defer license.ResetForTests() + + ev := &models.TimeEntryDeletedEvent{TimeEntry: &models.TimeEntry{ID: 4, UserID: 1}} + events.TestListener(t, ev, &TimeEntryListener{wsEvent: "timer.deleted"}) + + require.Len(t, conn.send, 1) + msg := <-conn.send + assert.Equal(t, "timer.deleted", msg.Event) + te, ok := msg.Data.(*models.TimeEntry) + require.True(t, ok) + assert.Equal(t, int64(4), te.ID) + }) + + t.Run("does not push when the feature is disabled", func(t *testing.T) { + InitHub() + conn := timerConn(1) + GetHub().Register(conn) + license.ResetForTests() // free mode + + ev := &models.TimeEntryUpdatedEvent{TimeEntry: &models.TimeEntry{ID: 4, UserID: 1}} + events.TestListener(t, ev, &TimeEntryListener{wsEvent: "timer.updated"}) + + assert.Empty(t, conn.send) + }) + + t.Run("only pushes to the entry owner", func(t *testing.T) { + InitHub() + other := timerConn(2) + GetHub().Register(other) + license.SetForTests([]license.Feature{license.FeatureTimeTracking}) + defer license.ResetForTests() + + ev := &models.TimeEntryUpdatedEvent{TimeEntry: &models.TimeEntry{ID: 4, UserID: 1}} + events.TestListener(t, ev, &TimeEntryListener{wsEvent: "timer.updated"}) + + assert.Empty(t, other.send, "a different user must not receive the timer update") + }) +} From aef584c9fa2af8448d016eae72648f5b1517d19d Mon Sep 17 00:00:00 2001 From: kolaente Date: Mon, 8 Jun 2026 15:11:07 +0200 Subject: [PATCH 011/214] feat(time-tracking): let clients subscribe to timer events --- pkg/websocket/connection.go | 3 +++ pkg/websocket/connection_test.go | 14 ++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/pkg/websocket/connection.go b/pkg/websocket/connection.go index 0438b6fef..a2fb54d47 100644 --- a/pkg/websocket/connection.go +++ b/pkg/websocket/connection.go @@ -264,6 +264,9 @@ func (c *Connection) WriteLoop(ctx context.Context, cancel context.CancelFunc) { // validEvents is the set of event names clients are allowed to subscribe to. var validEvents = map[string]bool{ "notification.created": true, + "timer.created": true, + "timer.updated": true, + "timer.deleted": true, } func isValidEvent(event string) bool { diff --git a/pkg/websocket/connection_test.go b/pkg/websocket/connection_test.go index f5bccdac2..d05ab5c1e 100644 --- a/pkg/websocket/connection_test.go +++ b/pkg/websocket/connection_test.go @@ -80,6 +80,20 @@ func TestConnectionRejectsInvalidEvent(t *testing.T) { assert.False(t, conn.IsSubscribed("notifications")) } +func TestConnectionAllowsTimerEvents(t *testing.T) { + conn := &Connection{ + userID: 1, + authenticated: true, + subscriptions: make(map[string]bool), + send: make(chan OutgoingMessage, 16), + } + + for _, event := range []string{"timer.created", "timer.updated"} { + conn.handleMessage(context.Background(), IncomingMessage{Action: ActionSubscribe, Event: event}) + assert.True(t, conn.IsSubscribed(event), "client must be able to subscribe to %s", event) + } +} + func TestConnectionRejectsActionsBeforeAuth(t *testing.T) { conn := &Connection{ userID: 0, // not authenticated From b8b376c53a5bf92d4cdeca741939f19681ecc22e Mon Sep 17 00:00:00 2001 From: kolaente Date: Mon, 8 Jun 2026 15:11:07 +0200 Subject: [PATCH 012/214] test(time-tracking): cover the time_entries model --- pkg/db/fixtures/time_entries.yml | 36 ++ pkg/models/models.go | 1 + pkg/models/setup_tests.go | 1 + pkg/models/time_tracking_test.go | 678 +++++++++++++++++++++++++++++++ 4 files changed, 716 insertions(+) create mode 100644 pkg/db/fixtures/time_entries.yml create mode 100644 pkg/models/time_tracking_test.go diff --git a/pkg/db/fixtures/time_entries.yml b/pkg/db/fixtures/time_entries.yml new file mode 100644 index 000000000..a3a0112fb --- /dev/null +++ b/pkg/db/fixtures/time_entries.yml @@ -0,0 +1,36 @@ +- id: 1 + user_id: 1 + task_id: 1 + project_id: 0 + start_time: 2018-12-01 10:00:00 + end_time: 2018-12-01 11:00:00 + comment: Time entry on task 1 + created: 2018-12-01 15:13:12 + updated: 2018-12-02 15:13:12 +- id: 2 + user_id: 1 + task_id: 0 + project_id: 1 + start_time: 2018-12-01 12:00:00 + end_time: 2018-12-01 13:00:00 + comment: Standalone entry on project 1 + created: 2018-12-01 15:13:12 + updated: 2018-12-02 15:13:12 +- id: 3 + user_id: 3 + task_id: 0 + project_id: 3 + start_time: 2018-12-01 12:00:00 + end_time: 2018-12-01 13:00:00 + comment: Standalone entry on project 3 by user3 + created: 2018-12-01 15:13:12 + updated: 2018-12-02 15:13:12 +# Running timer (no end_time) on task 1 by user1 +- id: 4 + user_id: 1 + task_id: 1 + project_id: 0 + start_time: 2018-12-01 14:00:00 + comment: Running timer + created: 2018-12-01 15:13:12 + updated: 2018-12-02 15:13:12 diff --git a/pkg/models/models.go b/pkg/models/models.go index df562ef90..88d98231c 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -71,6 +71,7 @@ func GetTables() []interface{} { &TaskUnreadStatus{}, &Session{}, &OAuthCode{}, + &TimeEntry{}, } } diff --git a/pkg/models/setup_tests.go b/pkg/models/setup_tests.go index 66948fc84..15b056faa 100644 --- a/pkg/models/setup_tests.go +++ b/pkg/models/setup_tests.go @@ -59,6 +59,7 @@ func SetupTests() { "task_relations", "task_reminders", "tasks", + "time_entries", "team_projects", "team_members", "teams", diff --git a/pkg/models/time_tracking_test.go b/pkg/models/time_tracking_test.go new file mode 100644 index 000000000..deb1892fa --- /dev/null +++ b/pkg/models/time_tracking_test.go @@ -0,0 +1,678 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package models + +import ( + "encoding/json" + "testing" + "time" + + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/events" + "code.vikunja.io/api/pkg/license" + "code.vikunja.io/api/pkg/user" + "code.vikunja.io/api/pkg/web" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func timePtr(t time.Time) *time.Time { return &t } + +// Fixture access graph (pkg/db/fixtures): project 1 is owned by user1 only +// (everyone else a stranger); task 1 lives in project 1. Project 3 is owned by +// user3, with user1 and user2 granted read. user4 has access to neither. +// Entries: 1 = user1 on task 1, 2 = user1 on project 1, 3 = user3 on project 3. + +func TestTimeEntry_CanRead(t *testing.T) { + tests := []struct { + name string + entryID int64 + auth web.Auth + wantCan bool + wantErr func(error) bool + }{ + {"owner reads task entry", 1, &user.User{ID: 1}, true, nil}, + {"owner reads project entry", 2, &user.User{ID: 1}, true, nil}, + {"reader reads other user's entry on a shared project", 3, &user.User{ID: 1}, true, nil}, + {"stranger denied on owned project", 1, &user.User{ID: 4}, false, nil}, + {"stranger denied on shared project", 3, &user.User{ID: 4}, false, nil}, + {"link share denied", 1, &LinkSharing{ID: 1, ProjectID: 1}, false, nil}, + {"missing entry is a 404", 999, &user.User{ID: 1}, false, IsErrTimeEntryDoesNotExist}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + can, _, err := (&TimeEntry{ID: tt.entryID}).CanRead(s, tt.auth) + if tt.wantErr != nil { + require.Error(t, err) + assert.True(t, tt.wantErr(err), "unexpected error type: %v", err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantCan, can) + }) + } +} + +func TestTimeEntry_CanCreate(t *testing.T) { + tests := []struct { + name string + entry *TimeEntry + auth web.Auth + wantCan bool + wantErr func(error) bool + }{ + {"on a task in an owned project", &TimeEntry{TaskID: 1}, &user.User{ID: 1}, true, nil}, + {"on an owned project", &TimeEntry{ProjectID: 1}, &user.User{ID: 1}, true, nil}, + {"on a readable project", &TimeEntry{ProjectID: 3}, &user.User{ID: 1}, true, nil}, + {"stranger denied", &TimeEntry{ProjectID: 1}, &user.User{ID: 4}, false, nil}, + {"both task and project is invalid", &TimeEntry{TaskID: 1, ProjectID: 1}, &user.User{ID: 1}, false, IsErrTimeEntryInvalidContainer}, + {"neither task nor project is invalid", &TimeEntry{}, &user.User{ID: 1}, false, IsErrTimeEntryInvalidContainer}, + {"link share denied", &TimeEntry{ProjectID: 1}, &LinkSharing{ID: 1, ProjectID: 1}, false, nil}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + can, err := tt.entry.CanCreate(s, tt.auth) + if tt.wantErr != nil { + require.Error(t, err) + assert.True(t, tt.wantErr(err), "unexpected error type: %v", err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantCan, can) + }) + } +} + +// Entry 3 is authored by user3; user1 can read project 3 but is not the author, +// so it can read but not modify. +func TestTimeEntry_CanModify(t *testing.T) { + tests := []struct { + name string + entryID int64 + auth web.Auth + wantCan bool + }{ + {"author modifies own entry", 1, &user.User{ID: 1}, true}, + {"author modifies own entry on shared project", 3, &user.User{ID: 3}, true}, + {"reader who is not author cannot modify", 3, &user.User{ID: 1}, false}, + {"stranger cannot modify", 3, &user.User{ID: 4}, false}, + {"link share cannot modify", 1, &LinkSharing{ID: 1, ProjectID: 1}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + canUpdate, err := (&TimeEntry{ID: tt.entryID}).CanUpdate(s, tt.auth) + require.NoError(t, err) + assert.Equal(t, tt.wantCan, canUpdate, "CanUpdate") + + canDelete, err := (&TimeEntry{ID: tt.entryID}).CanDelete(s, tt.auth) + require.NoError(t, err) + assert.Equal(t, tt.wantCan, canDelete, "CanDelete") + }) + } +} + +// Guards the data leak: ReadAll must return only entries on tasks/projects the +// caller can read, since DoReadAll runs no permission check. +func TestTimeEntry_ReadAll(t *testing.T) { + tests := []struct { + name string + auth web.Auth + wantIDs []int64 + }{ + {"user sees every readable entry", &user.User{ID: 1}, []int64{1, 2, 3, 4}}, + {"user sees only entries on projects they can read", &user.User{ID: 2}, []int64{3}}, + {"stranger sees nothing", &user.User{ID: 4}, []int64{}}, + {"link share sees nothing", &LinkSharing{ID: 1, ProjectID: 1}, []int64{}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + result, count, total, err := (&TimeEntry{}).ReadAll(s, tt.auth, "", 1, 50) + require.NoError(t, err) + entries, ok := result.([]*TimeEntry) + require.True(t, ok) + + gotIDs := make([]int64, 0, len(entries)) + for _, e := range entries { + gotIDs = append(gotIDs, e.ID) + } + assert.ElementsMatch(t, tt.wantIDs, gotIDs) + assert.Equal(t, len(tt.wantIDs), count) + assert.Equal(t, int64(len(tt.wantIDs)), total) + }) + } +} + +// Filtering reuses the task filter grammar. user1 can read entries 1,2,4 +// (project 1) and 3 (project 3, shared) — the filter only narrows that set. +func TestTimeEntry_ReadAll_Filter(t *testing.T) { + tests := []struct { + name string + filter string + wantIDs []int64 + wantErr bool + }{ + {"by user", "user_id = 3", []int64{3}, false}, + {"by task", "task_id = 1", []int64{1, 4}, false}, + {"by project unions task-attached entries", "project_id = 1", []int64{1, 2, 4}, false}, + {"by project negated", "project_id != 1", []int64{3}, false}, + {"by start time", "start_time > '2018-12-01T11:00:00+00:00'", []int64{2, 3, 4}, false}, + {"running timers via null end_time", "end_time = null", []int64{4}, false}, + {"compound and", "user_id = 1 && end_time = null", []int64{4}, false}, + {"compound or", "user_id = 3 || task_id = 1", []int64{1, 3, 4}, false}, + {"in list", "user_id in 1,3", []int64{1, 2, 3, 4}, false}, + {"comment is not filterable", "comment = whatever", nil, true}, + {"unknown field errors", "bogus = 1", nil, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + te := &TimeEntry{Filter: tt.filter} + result, _, _, err := te.ReadAll(s, &user.User{ID: 1}, "", 1, 50) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + entries, ok := result.([]*TimeEntry) + require.True(t, ok) + gotIDs := make([]int64, 0, len(entries)) + for _, e := range entries { + gotIDs = append(gotIDs, e.ID) + } + assert.ElementsMatch(t, tt.wantIDs, gotIDs) + }) + } +} + +// Search matches the entry comment. Comments: 1="Time entry on task 1", +// 2/3 contain "Standalone", 4="Running timer". +func TestTimeEntry_ReadAll_Search(t *testing.T) { + tests := []struct { + name string + search string + wantIDs []int64 + }{ + {"matches a comment", "Running", []int64{4}}, + {"is case-insensitive", "running", []int64{4}}, + {"matches several", "Standalone", []int64{2, 3}}, + {"no match", "nothing matches this", []int64{}}, + {"empty search returns all readable", "", []int64{1, 2, 3, 4}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + result, _, _, err := (&TimeEntry{}).ReadAll(s, &user.User{ID: 1}, tt.search, 1, 50) + require.NoError(t, err) + entries, ok := result.([]*TimeEntry) + require.True(t, ok) + gotIDs := make([]int64, 0, len(entries)) + for _, e := range entries { + gotIDs = append(gotIDs, e.ID) + } + assert.ElementsMatch(t, tt.wantIDs, gotIDs) + }) + } +} + +func TestTimeEntry_Create(t *testing.T) { + t.Run("manual entry keeps its start time and is owned by the caller", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + start := time.Date(2020, 1, 1, 9, 0, 0, 0, time.UTC) + end := time.Date(2020, 1, 1, 10, 0, 0, 0, time.UTC) + te := &TimeEntry{TaskID: 1, StartTime: start, EndTime: &end, Comment: "work"} + require.NoError(t, te.Create(s, &user.User{ID: 1})) + require.NoError(t, s.Commit()) + + assert.Equal(t, int64(1), te.UserID) + assert.True(t, te.StartTime.Equal(start)) + db.AssertExists(t, "time_entries", map[string]interface{}{ + "id": te.ID, + "user_id": 1, + "task_id": 1, + "comment": "work", + }, false) + }) + + t.Run("defaults the start time to now when none is given", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + te := &TimeEntry{TaskID: 1} + require.NoError(t, te.Create(s, &user.User{ID: 1})) + assert.False(t, te.StartTime.IsZero()) + }) + + t.Run("a completed manual entry leaves a running timer alone", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + // entry 4 is user1's running timer + manual := &TimeEntry{ + TaskID: 1, + StartTime: time.Date(2020, 1, 1, 9, 0, 0, 0, time.UTC), + EndTime: timePtr(time.Date(2020, 1, 1, 10, 0, 0, 0, time.UTC)), + } + require.NoError(t, manual.Create(s, &user.User{ID: 1})) + require.NoError(t, s.Commit()) + + running := &TimeEntry{} + exists, err := s.Where("id = ?", 4).Get(running) + require.NoError(t, err) + require.True(t, exists) + assert.Nil(t, running.EndTime, "a manual entry must not stop the running timer") + }) + + t.Run("auto-stops the caller's running timer", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + a := &user.User{ID: 1} + + first := &TimeEntry{TaskID: 1} + require.NoError(t, first.Create(s, a)) + require.Nil(t, first.EndTime, "first timer should be running") + + second := &TimeEntry{TaskID: 1} + require.NoError(t, second.Create(s, a)) + require.NoError(t, s.Commit()) + + reloaded := &TimeEntry{} + exists, err := s.Where("id = ?", first.ID).Get(reloaded) + require.NoError(t, err) + require.True(t, exists) + assert.NotNil(t, reloaded.EndTime, "first timer should have been auto-stopped") + assert.Nil(t, second.EndTime, "second timer should still be running") + }) +} + +// A running timer (no end) must round-trip as a NULL end_time: found by the +// null filter and serialized as JSON null, never the 0001-01-01 zero sentinel. +func TestTimeEntry_RunningTimerEndTimeIsNull(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + a := &user.User{ID: 1} + + te := &TimeEntry{TaskID: 1, StartTime: time.Date(2020, 1, 1, 9, 0, 0, 0, time.UTC)} + require.NoError(t, te.Create(s, a)) + require.NoError(t, s.Commit()) + + reloaded, err := getTimeEntryByID(s, te.ID) + require.NoError(t, err) + + marshalled, err := json.Marshal(reloaded) + require.NoError(t, err) + assert.Contains(t, string(marshalled), `"end_time":null`) + assert.NotContains(t, string(marshalled), "0001-01-01") + + // Stored as NULL, so the null filter matches it (not just the fixtures). + found := &TimeEntry{Filter: "end_time = null"} + result, _, _, err := found.ReadAll(s, a, "", 1, 50) + require.NoError(t, err) + ids := []int64{} + for _, e := range result.([]*TimeEntry) { + ids = append(ids, e.ID) + } + assert.Contains(t, ids, te.ID) +} + +// Regression guard: the permission check must not clobber the update payload. +func TestTimeEntry_Update(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + a := &user.User{ID: 1} + + te := &TimeEntry{ + ID: 1, + TaskID: 1, + StartTime: time.Date(2020, 1, 1, 9, 0, 0, 0, time.UTC), + EndTime: timePtr(time.Date(2020, 1, 1, 10, 0, 0, 0, time.UTC)), + Comment: "updated comment", + } + + can, err := te.CanUpdate(s, a) // the handler calls this before Update + require.NoError(t, err) + require.True(t, can) + require.NoError(t, te.Update(s, a)) + require.NoError(t, s.Commit()) + + assert.Equal(t, "updated comment", te.Comment) + db.AssertExists(t, "time_entries", map[string]interface{}{ + "id": 1, + "comment": "updated comment", + }, false) +} + +func TestTimeEntry_UpdateReassignsContainer(t *testing.T) { + validTimes := func(te *TimeEntry) { + te.StartTime = time.Date(2020, 1, 1, 9, 0, 0, 0, time.UTC) + te.EndTime = timePtr(time.Date(2020, 1, 1, 10, 0, 0, 0, time.UTC)) + } + + t.Run("moves an entry from a task to a project", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + a := &user.User{ID: 1} + + // Entry 1 is on task 1; move it onto project 1 directly. + te := &TimeEntry{ID: 1, ProjectID: 1} + validTimes(te) + + can, err := te.CanUpdate(s, a) + require.NoError(t, err) + require.True(t, can) + require.NoError(t, te.Update(s, a)) + require.NoError(t, s.Commit()) + + db.AssertExists(t, "time_entries", map[string]interface{}{ + "id": 1, + "task_id": 0, + "project_id": 1, + }, false) + }) + + t.Run("rejects an update that sets both task and project", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + _, err := (&TimeEntry{ID: 1, TaskID: 1, ProjectID: 1}).CanUpdate(s, &user.User{ID: 1}) + require.Error(t, err) + assert.True(t, IsErrTimeEntryInvalidContainer(err)) + }) + + t.Run("an omitted container keeps the existing one", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + a := &user.User{ID: 1} + + // Entry 1 is on task 1; update only the comment, no container set. + te := &TimeEntry{ID: 1, Comment: "kept on task"} + validTimes(te) + + can, err := te.CanUpdate(s, a) + require.NoError(t, err) + require.True(t, can) + require.NoError(t, te.Update(s, a)) + require.NoError(t, s.Commit()) + + db.AssertExists(t, "time_entries", map[string]interface{}{ + "id": 1, + "task_id": 1, + "project_id": 0, + "comment": "kept on task", + }, false) + }) +} + +func TestTimeEntry_UpdateReopenGuard(t *testing.T) { + a := &user.User{ID: 1} + someStart := time.Date(2020, 1, 1, 9, 0, 0, 0, time.UTC) + + t.Run("rejects clearing the end of a completed entry", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + // Entry 1 is completed; a nil end would reopen it as a running timer. + te := &TimeEntry{ID: 1, TaskID: 1, StartTime: someStart} // EndTime nil + can, err := te.CanUpdate(s, a) + require.NoError(t, err) + require.True(t, can) + + err = te.Update(s, a) + require.Error(t, err) + assert.True(t, IsErrTimeEntryAlreadyEnded(err), "unexpected error type: %v", err) + }) + + t.Run("allows editing a running entry while it stays running", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + // Entry 4 is user1's running timer; keeping it running (nil end) is fine. + te := &TimeEntry{ID: 4, TaskID: 1, StartTime: someStart, Comment: "edited"} // EndTime nil + can, err := te.CanUpdate(s, a) + require.NoError(t, err) + require.True(t, can) + require.NoError(t, te.Update(s, a)) + }) +} + +func TestTimeEntry_StopRunningTimer(t *testing.T) { + t.Run("stops the caller's running timer and returns it", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + entry, err := StopRunningTimer(s, &user.User{ID: 1}) // entry 4 + require.NoError(t, err) + require.NoError(t, s.Commit()) + + assert.Equal(t, int64(4), entry.ID) + assert.NotNil(t, entry.EndTime) + + reloaded := &TimeEntry{} + _, err = s.Where("id = ?", 4).Get(reloaded) + require.NoError(t, err) + assert.NotNil(t, reloaded.EndTime, "end time should be persisted") + }) + + t.Run("errors when no timer is running", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + _, err := StopRunningTimer(s, &user.User{ID: 2}) // user2 has no entries + require.Error(t, err) + assert.True(t, IsErrNoRunningTimer(err), "unexpected error type: %v", err) + }) + + t.Run("denies a link share and leaves the matching user's timer running", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + // Share id 1 collides with user 1, whose entry 4 is a running timer. + _, err := StopRunningTimer(s, &LinkSharing{ID: 1, ProjectID: 1}) + require.Error(t, err) + assert.True(t, IsErrGenericForbidden(err), "unexpected error type: %v", err) + + running := &TimeEntry{} + exists, err := s.Where("id = ?", 4).Get(running) + require.NoError(t, err) + require.True(t, exists) + assert.Nil(t, running.EndTime, "the user's timer must not have been stopped by a link share") + }) +} + +func TestTimeEntry_Events(t *testing.T) { + u := &user.User{ID: 1} + someStart := time.Date(2020, 1, 1, 9, 0, 0, 0, time.UTC) + someEnd := timePtr(time.Date(2020, 1, 1, 10, 0, 0, 0, time.UTC)) + + t.Run("create dispatches created", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + events.ClearDispatchedEvents() + s := db.NewSession() + defer s.Close() + + te := &TimeEntry{TaskID: 1, StartTime: someStart, EndTime: someEnd} + require.NoError(t, te.Create(s, u)) + require.NoError(t, s.Commit()) + events.DispatchPending(s) + events.AssertDispatched(t, &TimeEntryCreatedEvent{}) + }) + + t.Run("update dispatches updated", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + events.ClearDispatchedEvents() + s := db.NewSession() + defer s.Close() + + te := &TimeEntry{ID: 1, TaskID: 1, StartTime: someStart, EndTime: someEnd, Comment: "edited"} + can, err := te.CanUpdate(s, u) + require.NoError(t, err) + require.True(t, can) + require.NoError(t, te.Update(s, u)) + require.NoError(t, s.Commit()) + events.DispatchPending(s) + events.AssertDispatched(t, &TimeEntryUpdatedEvent{}) + }) + + t.Run("delete dispatches deleted", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + events.ClearDispatchedEvents() + s := db.NewSession() + defer s.Close() + + require.NoError(t, (&TimeEntry{ID: 1}).Delete(s, u)) + require.NoError(t, s.Commit()) + events.DispatchPending(s) + events.AssertDispatched(t, &TimeEntryDeletedEvent{}) + }) + + t.Run("starting a timer dispatches created plus updated for the auto-stopped entry", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + events.ClearDispatchedEvents() + s := db.NewSession() + defer s.Close() + + // entry 4 is user1's running timer; a new running timer auto-stops it + require.NoError(t, (&TimeEntry{TaskID: 1}).Create(s, u)) + require.NoError(t, s.Commit()) + events.DispatchPending(s) + events.AssertDispatched(t, &TimeEntryCreatedEvent{}) + events.AssertDispatched(t, &TimeEntryUpdatedEvent{}) + }) + + t.Run("a completed manual entry dispatches only created", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + events.ClearDispatchedEvents() + s := db.NewSession() + defer s.Close() + + te := &TimeEntry{TaskID: 1, StartTime: someStart, EndTime: someEnd} + require.NoError(t, te.Create(s, u)) + require.NoError(t, s.Commit()) + events.DispatchPending(s) + assert.Equal(t, 1, events.CountDispatchedEvents((&TimeEntryCreatedEvent{}).Name())) + assert.Equal(t, 0, events.CountDispatchedEvents((&TimeEntryUpdatedEvent{}).Name()), "a completed manual entry must not auto-stop") + }) + + t.Run("StopRunningTimer dispatches updated", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + events.ClearDispatchedEvents() + s := db.NewSession() + defer s.Close() + + _, err := StopRunningTimer(s, u) + require.NoError(t, err) + require.NoError(t, s.Commit()) + events.DispatchPending(s) + events.AssertDispatched(t, &TimeEntryUpdatedEvent{}) + }) +} + +func TestTimeEntry_Delete(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + require.NoError(t, (&TimeEntry{ID: 1}).Delete(s, &user.User{ID: 1})) + require.NoError(t, s.Commit()) + db.AssertMissing(t, "time_entries", map[string]interface{}{"id": 1}) +} + +func TestTimeEntry_TaskCount(t *testing.T) { + u := &user.User{ID: 1} + + t.Run("attaches counts for a licensed, non-share caller", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + license.SetForTests([]license.Feature{license.FeatureTimeTracking}) + defer license.ResetForTests() + + task1 := &Task{ID: 1} // fixtures: time entries 1 and 4 are attached to task 1 + task2 := &Task{ID: 2} // no time entries + taskMap := map[int64]*Task{1: task1, 2: task2} + + require.NoError(t, addTimeEntriesCountToTasks(s, u, []int64{1, 2}, taskMap)) + + require.NotNil(t, task1.TimeEntriesCount) + assert.Equal(t, int64(2), *task1.TimeEntriesCount) + require.NotNil(t, task2.TimeEntriesCount) + assert.Equal(t, int64(0), *task2.TimeEntriesCount) + }) + + t.Run("leaves the count unset for a link share", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + license.SetForTests([]license.Feature{license.FeatureTimeTracking}) + defer license.ResetForTests() + + task1 := &Task{ID: 1} + taskMap := map[int64]*Task{1: task1} + require.NoError(t, addTimeEntriesCountToTasks(s, &LinkSharing{ID: 1}, []int64{1}, taskMap)) + assert.Nil(t, task1.TimeEntriesCount, "link shares must not learn time-entry counts") + }) + + t.Run("leaves the count unset when the feature is unlicensed", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + license.ResetForTests() // feature disabled + + task1 := &Task{ID: 1} + taskMap := map[int64]*Task{1: task1} + require.NoError(t, addTimeEntriesCountToTasks(s, u, []int64{1}, taskMap)) + assert.Nil(t, task1.TimeEntriesCount, "an unlicensed instance must not expose counts") + }) +} From 2858b8b8276ccf2da90379bfd3a72221bfb632d1 Mon Sep 17 00:00:00 2001 From: kolaente Date: Mon, 8 Jun 2026 15:11:07 +0200 Subject: [PATCH 013/214] test(time-tracking): cover the v2 time-entry routes --- pkg/webtests/huma_time_entry_test.go | 211 +++++++++++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 pkg/webtests/huma_time_entry_test.go diff --git a/pkg/webtests/huma_time_entry_test.go b/pkg/webtests/huma_time_entry_test.go new file mode 100644 index 000000000..1b8b91ffb --- /dev/null +++ b/pkg/webtests/huma_time_entry_test.go @@ -0,0 +1,211 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "encoding/json" + "net/http" + "net/url" + "testing" + + "code.vikunja.io/api/pkg/license" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Fixture entries (pkg/db/fixtures/time_entries.yml): 1 = user1 on task 1, +// 2 = user1 on project 1, 3 = user3 on project 3 (user1 can read), 4 = user1's +// running timer on task 1. user1 (testuser1) can read all four. + +// The gate is the one v2-specific concern with no model-level equivalent: every +// time-tracking route 404s on an instance without the feature. +func TestHumaTimeEntry_LicenseGate(t *testing.T) { + t.Run("disabled feature 404s the list", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + license.SetForTests([]license.Feature{}) // licensed, but not time tracking + defer license.ResetForTests() + + rec := humaRequest(t, e, http.MethodGet, "/api/v2/time-entries", "", humaTokenFor(t, &testuser1), "") + assert.Equal(t, http.StatusNotFound, rec.Code) + }) + + t.Run("disabled feature 404s timer/stop", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + license.SetForTests([]license.Feature{}) + defer license.ResetForTests() + + rec := humaRequest(t, e, http.MethodPost, "/api/v2/time-entries/timer/stop", "", humaTokenFor(t, &testuser1), "") + assert.Equal(t, http.StatusNotFound, rec.Code) + }) + + t.Run("disabled feature 404s the task-scoped list", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + license.SetForTests([]license.Feature{}) + defer license.ResetForTests() + + rec := humaRequest(t, e, http.MethodGet, "/api/v2/tasks/1/time-entries", "", humaTokenFor(t, &testuser1), "") + assert.Equal(t, http.StatusNotFound, rec.Code) + }) + + t.Run("enabled feature serves the list", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + license.SetForTests([]license.Feature{license.FeatureTimeTracking}) + defer license.ResetForTests() + + rec := humaRequest(t, e, http.MethodGet, "/api/v2/time-entries", "", humaTokenFor(t, &testuser1), "") + assert.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + }) +} + +func TestHumaTimeEntry(t *testing.T) { + // SetForTests must come after setupTestEnv — the latter re-inits the license to free. + e, err := setupTestEnv() + require.NoError(t, err) + license.SetForTests([]license.Feature{license.FeatureTimeTracking}) + defer license.ResetForTests() + + testHandler := webHandlerTestV2{ + user: &testuser1, + basePath: "/api/v2/time-entries", + idParam: "id", + t: t, + e: e, + } + + t.Run("ReadAll returns the readable set", func(t *testing.T) { + rec, err := testHandler.testReadAllWithUser(nil, nil) + require.NoError(t, err) + assert.ElementsMatch(t, []int64{1, 2, 3, 4}, timeEntryIDsFromReadAll(t, rec.Body.Bytes()), + "body: %s", rec.Body.String()) + }) + + t.Run("ReadOne", func(t *testing.T) { + rec, err := testHandler.testReadOneWithUser(nil, map[string]string{"id": "1"}) + require.NoError(t, err) + assert.Contains(t, rec.Body.String(), `"id":1`) + assert.Contains(t, rec.Body.String(), `"max_permission":`) + }) + + t.Run("ReadOne forbidden for a stranger", func(t *testing.T) { + // entry 1 is on project 1; user2 has no access to it. + stranger := webHandlerTestV2{user: &testuser2, basePath: "/api/v2/time-entries", idParam: "id", t: t, e: e} + _, err := stranger.testReadOneWithUser(nil, map[string]string{"id": "1"}) + require.Error(t, err) + assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err)) + }) + + t.Run("ReadOne of a missing entry is 404", func(t *testing.T) { + _, err := testHandler.testReadOneWithUser(nil, map[string]string{"id": "9999"}) + require.Error(t, err) + assert.Equal(t, http.StatusNotFound, getHTTPErrorCode(err)) + }) +} + +// Create exercises the full handler path (DoCreate → CanCreate → Create → +// commit → DispatchPending) that the model-level tests bypass. +func TestHumaTimeEntry_Create(t *testing.T) { + t.Run("saving an entry with end_time", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + license.SetForTests([]license.Feature{license.FeatureTimeTracking}) + defer license.ResetForTests() + + body := `{"task_id":1,"start_time":"2020-01-01T09:00:00Z","end_time":"2020-01-01T10:00:00Z","comment":"work"}` + rec := humaRequest(t, e, http.MethodPost, "/api/v2/time-entries", body, humaTokenFor(t, &testuser1), "") + require.Equal(t, http.StatusCreated, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"user_id":1`) + assert.Contains(t, rec.Body.String(), `"task_id":1`) + }) + + t.Run("starting a timer without end_time", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + license.SetForTests([]license.Feature{license.FeatureTimeTracking}) + defer license.ResetForTests() + + body := `{"task_id":1,"start_time":"2020-01-01T09:00:00Z","comment":"timer"}` + rec := humaRequest(t, e, http.MethodPost, "/api/v2/time-entries", body, humaTokenFor(t, &testuser1), "") + require.Equal(t, http.StatusCreated, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"user_id":1`) + // A running timer's end_time is null on the wire, not the zero-time sentinel. + assert.Contains(t, rec.Body.String(), `"end_time":null`) + assert.NotContains(t, rec.Body.String(), "0001-01-01") + }) +} + +// The filter param must wire through the route into ReadAll. +func TestHumaTimeEntry_Filter(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + license.SetForTests([]license.Feature{license.FeatureTimeTracking}) + defer license.ResetForTests() + token := humaTokenFor(t, &testuser1) + + t.Run("by task", func(t *testing.T) { + q := url.Values{"filter": {"task_id = 1"}} + rec := humaRequest(t, e, http.MethodGet, "/api/v2/time-entries?"+q.Encode(), "", token, "") + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.ElementsMatch(t, []int64{1, 4}, timeEntryIDsFromReadAll(t, rec.Body.Bytes())) + }) + + t.Run("running timers via null end_time", func(t *testing.T) { + q := url.Values{"filter": {"end_time = null"}} + rec := humaRequest(t, e, http.MethodGet, "/api/v2/time-entries?"+q.Encode(), "", token, "") + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.ElementsMatch(t, []int64{4}, timeEntryIDsFromReadAll(t, rec.Body.Bytes())) + }) +} + +func TestHumaTimeEntry_TimerStop(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + license.SetForTests([]license.Feature{license.FeatureTimeTracking}) + defer license.ResetForTests() + + t.Run("stops the caller's running timer", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodPost, "/api/v2/time-entries/timer/stop", "", humaTokenFor(t, &testuser1), "") + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"id":4`, "entry 4 is user1's running timer") + assert.NotContains(t, rec.Body.String(), `"end_time":"0001-01-01`, "end_time must now be set") + }) + + t.Run("404 when the caller has no running timer", func(t *testing.T) { + // user2 has no entries, so no running timer. + rec := humaRequest(t, e, http.MethodPost, "/api/v2/time-entries/timer/stop", "", humaTokenFor(t, &testuser2), "") + assert.Equal(t, http.StatusNotFound, rec.Code, rec.Body.String()) + }) +} + +func timeEntryIDsFromReadAll(t *testing.T, body []byte) []int64 { + t.Helper() + var resp struct { + Items []struct { + ID int64 `json:"id"` + } `json:"items"` + } + require.NoError(t, json.Unmarshal(body, &resp), "ReadAll body must be a paginated envelope: %s", string(body)) + ids := make([]int64, 0, len(resp.Items)) + for _, it := range resp.Items { + ids = append(ids, it.ID) + } + return ids +} From 74510bb00a6bcc60da6e91c8e0e7225ad920ab8a Mon Sep 17 00:00:00 2001 From: kolaente Date: Mon, 8 Jun 2026 15:14:33 +0200 Subject: [PATCH 014/214] fix(api/v2): group time-entries token routes under their own scope --- pkg/models/api_routes.go | 1 + pkg/models/api_routes_test.go | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/pkg/models/api_routes.go b/pkg/models/api_routes.go index 2264a5121..d6300c51c 100644 --- a/pkg/models/api_routes.go +++ b/pkg/models/api_routes.go @@ -183,6 +183,7 @@ func isStandardCRUDRoute(routeGroupName string, routeParts []string, _ string) b "comments": true, "relations": true, "attachments": true, + "time-entries": true, "projects_views": true, "projects_teams": true, "projects_users": true, diff --git a/pkg/models/api_routes_test.go b/pkg/models/api_routes_test.go index b4b0e1661..5537c90b5 100644 --- a/pkg/models/api_routes_test.go +++ b/pkg/models/api_routes_test.go @@ -121,6 +121,32 @@ func TestCollectRoutesV2(t *testing.T) { assert.Equal(t, "DELETE", labels["delete"].Method) } +// TestCollectRoutes_TimeEntriesV2 verifies the v2-only time-entries resource +// lands under a clean "time-entries" group rather than the "other" catch-all, +// so its scopes read sensibly for token clients. +func TestCollectRoutes_TimeEntriesV2(t *testing.T) { + apiTokenRoutes = make(map[string]APITokenRoute) + apiTokenRoutesV2 = make(map[string]APITokenRoute) + + CollectRoutesForAPITokenUsage(echo.RouteInfo{Method: "GET", Path: "/api/v2/time-entries"}, true) + CollectRoutesForAPITokenUsage(echo.RouteInfo{Method: "GET", Path: "/api/v2/time-entries/:id"}, true) + CollectRoutesForAPITokenUsage(echo.RouteInfo{Method: "POST", Path: "/api/v2/time-entries"}, true) + CollectRoutesForAPITokenUsage(echo.RouteInfo{Method: "PUT", Path: "/api/v2/time-entries/:id"}, true) + CollectRoutesForAPITokenUsage(echo.RouteInfo{Method: "DELETE", Path: "/api/v2/time-entries/:id"}, true) + + _, isOther := apiTokenRoutesV2["other"] + assert.False(t, isOther, "time-entries CRUD must not fall into the 'other' bucket") + + te, has := apiTokenRoutesV2["time-entries"] + require.True(t, has, "time-entries group should exist in the v2 table") + assert.Equal(t, "GET", te["read_all"].Method) + assert.Equal(t, "/api/v2/time-entries", te["read_all"].Path) + assert.Equal(t, "GET", te["read_one"].Method) + assert.Equal(t, "POST", te["create"].Method) + assert.Equal(t, "PUT", te["update"].Method) + assert.Equal(t, "DELETE", te["delete"].Method) +} + // TestGetRouteDetail_V2Verbs verifies the v2 verb mapping: POST→create, // PUT/PATCH→update. v1 inverts POST and PUT so we need a separate mapping // path. From 4a558fc57abe2c713cfad098d50f38685af19995 Mon Sep 17 00:00:00 2001 From: kolaente Date: Mon, 8 Jun 2026 15:14:34 +0200 Subject: [PATCH 015/214] fix(api/v2): expose v2-only token route groups via the routes endpoint --- pkg/models/api_routes.go | 30 +++++++++++++++++++++++++----- pkg/models/api_routes_test.go | 23 +++++++++++++++++++++++ 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/pkg/models/api_routes.go b/pkg/models/api_routes.go index d6300c51c..24a3c747b 100644 --- a/pkg/models/api_routes.go +++ b/pkg/models/api_routes.go @@ -29,8 +29,8 @@ var apiTokenRoutes = map[string]APITokenRoute{} // apiTokenRoutesV2 holds /api/v2 routes under the same (group, permission) // keys as v1, so a token granted e.g. labels.read_one authorises both -// versions. The frontend token UI still reads only apiTokenRoutes; -// CanDoAPIRoute consults both tables. +// versions. CanDoAPIRoute consults both tables; GetAPITokenRoutes (the /routes +// exposure the frontend reads) merges v2-only groups so they're discoverable. var apiTokenRoutesV2 = map[string]APITokenRoute{} func init() { @@ -346,10 +346,30 @@ func CollectRoutesForAPITokenUsage(route echo.RouteInfo, requiresJWT bool) { } -// GetAPITokenRoutes exposes the registered scoped-token routes so tests -// and the /api/v1/routes handler share a single source of truth. +// GetAPITokenRoutes exposes the registered scoped-token routes for the /routes +// handler and tests. v1 is the base; v2-only groups and permissions (a v2-only +// resource like time-entries has no v1 counterpart) are merged in so tokens can +// discover and grant them. Shared (group, permission) keys keep their v1 entry — +// CanDoAPIRoute authorises both versions off the same key regardless. func GetAPITokenRoutes() map[string]APITokenRoute { - return apiTokenRoutes + merged := make(map[string]APITokenRoute, len(apiTokenRoutes)) + for group, perms := range apiTokenRoutes { + merged[group] = make(APITokenRoute, len(perms)) + for perm, rd := range perms { + merged[group][perm] = rd + } + } + for group, perms := range apiTokenRoutesV2 { + if merged[group] == nil { + merged[group] = make(APITokenRoute) + } + for perm, rd := range perms { + if merged[group][perm] == nil { + merged[group][perm] = rd + } + } + } + return merged } // GetAvailableAPIRoutesForToken returns a list of all API routes which are available for token usage. diff --git a/pkg/models/api_routes_test.go b/pkg/models/api_routes_test.go index 5537c90b5..5cc510a98 100644 --- a/pkg/models/api_routes_test.go +++ b/pkg/models/api_routes_test.go @@ -147,6 +147,29 @@ func TestCollectRoutes_TimeEntriesV2(t *testing.T) { assert.Equal(t, "DELETE", te["delete"].Method) } +// TestGetAPITokenRoutes_ExposesV2Only verifies the /routes payload merges +// v2-only groups (time-entries has no v1 counterpart) so token clients can +// discover and grant them, without mutating the v1 table itself. +func TestGetAPITokenRoutes_ExposesV2Only(t *testing.T) { + apiTokenRoutes = make(map[string]APITokenRoute) + apiTokenRoutesV2 = make(map[string]APITokenRoute) + + CollectRoutesForAPITokenUsage(echo.RouteInfo{Method: "GET", Path: "/api/v1/labels"}, true) + CollectRoutesForAPITokenUsage(echo.RouteInfo{Method: "GET", Path: "/api/v2/time-entries"}, true) + + routes := GetAPITokenRoutes() + + _, hasLabels := routes["labels"] + assert.True(t, hasLabels, "v1 groups stay exposed") + + te, hasTE := routes["time-entries"] + require.True(t, hasTE, "v2-only time-entries must be exposed via /routes") + assert.Equal(t, "GET", te["read_all"].Method) + + _, v1HasTE := apiTokenRoutes["time-entries"] + assert.False(t, v1HasTE, "the merge must not mutate the v1 table") +} + // TestGetRouteDetail_V2Verbs verifies the v2 verb mapping: POST→create, // PUT/PATCH→update. v1 inverts POST and PUT so we need a separate mapping // path. From 565bf97294ee6525e6063d3763c75d477696a589 Mon Sep 17 00:00:00 2001 From: kolaente Date: Mon, 8 Jun 2026 15:16:16 +0200 Subject: [PATCH 016/214] refactor(config): add PRO_FEATURE constants for licensed features --- frontend/src/constants/proFeatures.ts | 8 ++++++++ frontend/src/stores/config.ts | 3 ++- 2 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 frontend/src/constants/proFeatures.ts diff --git a/frontend/src/constants/proFeatures.ts b/frontend/src/constants/proFeatures.ts new file mode 100644 index 000000000..4e2af18ec --- /dev/null +++ b/frontend/src/constants/proFeatures.ts @@ -0,0 +1,8 @@ +// Licensed "pro" features the server may advertise via /info's enabled_pro_features. +// Use these instead of bare strings when calling configStore.isProFeatureEnabled. +export const PRO_FEATURE = { + ADMIN_PANEL: 'admin_panel', + TIME_TRACKING: 'time_tracking', +} as const + +export type ProFeature = typeof PRO_FEATURE[keyof typeof PRO_FEATURE] diff --git a/frontend/src/stores/config.ts b/frontend/src/stores/config.ts index 73160acd5..3eea0595a 100644 --- a/frontend/src/stores/config.ts +++ b/frontend/src/stores/config.ts @@ -7,6 +7,7 @@ import {objectToCamelCase} from '@/helpers/case' import type {IProvider} from '@/types/IProvider' import type {MIGRATORS} from '@/views/migrate/migrators' +import type {ProFeature} from '@/constants/proFeatures' import {InvalidApiUrlProvidedError} from '@/helpers/checkAndSetApiUrl' export interface ConfigState { @@ -104,7 +105,7 @@ export const useConfigStore = defineStore('config', () => { Object.assign(state, config) } - function isProFeatureEnabled(name: string): boolean { + function isProFeatureEnabled(name: ProFeature): boolean { return state.enabledProFeatures?.includes(name) ?? false } From 80c21e6f409d853728f28c8d14471bca6f70ec9e Mon Sep 17 00:00:00 2001 From: kolaente Date: Mon, 8 Jun 2026 15:16:16 +0200 Subject: [PATCH 017/214] feat(time-tracking): add the v2 time-entry service --- frontend/src/modelTypes/ITimeEntry.ts | 16 +++++ frontend/src/services/timeEntry.test.ts | 33 +++++++++ frontend/src/services/timeEntry.ts | 91 +++++++++++++++++++++++++ 3 files changed, 140 insertions(+) create mode 100644 frontend/src/modelTypes/ITimeEntry.ts create mode 100644 frontend/src/services/timeEntry.test.ts create mode 100644 frontend/src/services/timeEntry.ts diff --git a/frontend/src/modelTypes/ITimeEntry.ts b/frontend/src/modelTypes/ITimeEntry.ts new file mode 100644 index 000000000..249ce2129 --- /dev/null +++ b/frontend/src/modelTypes/ITimeEntry.ts @@ -0,0 +1,16 @@ +import type {IAbstract} from './IAbstract' + +export interface ITimeEntry extends IAbstract { + id: number + userId: number + // Exactly one of taskId / projectId is set (0 means unset). + taskId: number + projectId: number + startTime: Date + // null while the live timer is running. + endTime: Date | null + comment: string + + created: Date + updated: Date +} diff --git a/frontend/src/services/timeEntry.test.ts b/frontend/src/services/timeEntry.test.ts new file mode 100644 index 000000000..faa68bcc8 --- /dev/null +++ b/frontend/src/services/timeEntry.test.ts @@ -0,0 +1,33 @@ +import {describe, it, expect} from 'vitest' + +import {parseTimeEntry} from './timeEntry' + +describe('parseTimeEntry', () => { + it('maps snake_case keys and coerces dates', () => { + const e = parseTimeEntry({ + id: 1, + user_id: 2, + task_id: 3, + project_id: 0, + start_time: '2020-01-01T09:00:00Z', + end_time: '2020-01-01T10:00:00Z', + comment: 'work', + }) + expect(e.userId).toBe(2) + expect(e.taskId).toBe(3) + expect(e.comment).toBe('work') + expect(e.startTime).toBeInstanceOf(Date) + expect(e.endTime).toBeInstanceOf(Date) + }) + + it('treats a null end time as a running timer', () => { + const e = parseTimeEntry({ + id: 1, + user_id: 1, + task_id: 1, + start_time: '2020-01-01T09:00:00Z', + end_time: null, + }) + expect(e.endTime).toBeNull() + }) +}) diff --git a/frontend/src/services/timeEntry.ts b/frontend/src/services/timeEntry.ts new file mode 100644 index 000000000..b76a3d11a --- /dev/null +++ b/frontend/src/services/timeEntry.ts @@ -0,0 +1,91 @@ +import {AuthenticatedHTTPFactory, getApiBaseUrl} from '@/helpers/fetcher' +import {objectToCamelCase, objectToSnakeCase} from '@/helpers/case' + +import type {ITimeEntry} from '@/modelTypes/ITimeEntry' + +// Time tracking is the first frontend feature on /api/v2, while the shared +// AuthenticatedHTTPFactory pins baseURL to /api/v1. We hand axios absolute v2 +// URLs to bypass that. Bespoke and intentionally a bit dirty — to be folded +// into the proper service layer once the frontend moves fully onto v2. +function v2Url(path: string): string { + const v2Base = getApiBaseUrl().replace(/\/api\/v1\/$/, '/api/v2/') + return new URL(v2Base + path, window.location.origin).toString() +} + +export function parseTimeEntry(raw: Record): ITimeEntry { + const e = objectToCamelCase(raw) + const end = e.endTime as string | null | undefined + return { + id: e.id, + userId: e.userId, + taskId: e.taskId ?? 0, + projectId: e.projectId ?? 0, + startTime: new Date(e.startTime), + // null end_time = a running timer. + endTime: end ? new Date(end) : null, + comment: e.comment ?? '', + created: new Date(e.created), + updated: new Date(e.updated), + maxPermission: e.maxPermission ?? null, + } +} + +export interface TimeEntryListParams { + filter?: string + filterTimezone?: string + q?: string + page?: number + perPage?: number +} + +export interface TimeEntryListResult { + items: ITimeEntry[] + total: number + page: number + perPage: number + totalPages: number +} + +export function useTimeEntryService() { + const http = AuthenticatedHTTPFactory() + + async function getAll(params: TimeEntryListParams = {}): Promise { + const {data} = await http.get(v2Url('time-entries'), { + params: { + filter: params.filter, + filter_timezone: params.filterTimezone, + q: params.q, + page: params.page, + per_page: params.perPage, + }, + }) + return { + items: (data.items ?? []).map(parseTimeEntry), + total: data.total, + page: data.page, + perPage: data.per_page, + totalPages: data.total_pages, + } + } + + async function create(entry: Partial): Promise { + const {data} = await http.post(v2Url('time-entries'), objectToSnakeCase(entry)) + return parseTimeEntry(data) + } + + async function update(entry: Partial & {id: number}): Promise { + const {data} = await http.put(v2Url(`time-entries/${entry.id}`), objectToSnakeCase(entry)) + return parseTimeEntry(data) + } + + async function remove(id: number): Promise { + await http.delete(v2Url(`time-entries/${id}`)) + } + + async function stopTimer(): Promise { + const {data} = await http.post(v2Url('time-entries/timer/stop')) + return parseTimeEntry(data) + } + + return {getAll, create, update, remove, stopTimer} +} From 43d0203358285618bb8c1c633570a0f9ded764f6 Mon Sep 17 00:00:00 2001 From: kolaente Date: Mon, 8 Jun 2026 15:16:16 +0200 Subject: [PATCH 018/214] feat(time-tracking): add the time-tracking store --- frontend/src/stores/timeTracking.test.ts | 139 ++++++++++++++++++++++ frontend/src/stores/timeTracking.ts | 142 +++++++++++++++++++++++ 2 files changed, 281 insertions(+) create mode 100644 frontend/src/stores/timeTracking.test.ts create mode 100644 frontend/src/stores/timeTracking.ts diff --git a/frontend/src/stores/timeTracking.test.ts b/frontend/src/stores/timeTracking.test.ts new file mode 100644 index 000000000..62d3c7d09 --- /dev/null +++ b/frontend/src/stores/timeTracking.test.ts @@ -0,0 +1,139 @@ +import {describe, it, expect, beforeEach, vi} from 'vitest' +import {setActivePinia, createPinia} from 'pinia' + +import {useTimeTrackingStore} from './timeTracking' +import type {ITimeEntry} from '@/modelTypes/ITimeEntry' + +const {getAllMock, removeMock, authInfo} = vi.hoisted(() => ({ + getAllMock: vi.fn(), + removeMock: vi.fn(), + authInfo: {value: {id: 7} as {id: number} | null}, +})) + +vi.mock('@/services/timeEntry', async importOriginal => { + const actual = await importOriginal() + return { + ...actual, + useTimeEntryService: () => ({ + getAll: getAllMock, + remove: removeMock, + }), + } +}) + +vi.mock('@/stores/auth', () => ({ + useAuthStore: () => ({ + info: authInfo.value, + }), +})) + +function entry(id: number, endTime: Date | null): ITimeEntry { + return { + id, + userId: 1, + taskId: 1, + projectId: 0, + startTime: new Date(), + endTime, + comment: '', + created: new Date(), + updated: new Date(), + maxPermission: null, + } +} + +describe('timeTracking store', () => { + beforeEach(() => { + setActivePinia(createPinia()) + getAllMock.mockReset() + removeMock.mockReset() + authInfo.value = {id: 7} + }) + + it('a running entry becomes the active timer', () => { + const store = useTimeTrackingStore() + store.applyTimerEvent(entry(4, null)) + expect(store.activeTimer?.id).toBe(4) + expect(store.hasActiveTimer).toBe(true) + }) + + it('a stopped entry clears the matching active timer', () => { + const store = useTimeTrackingStore() + store.applyTimerEvent(entry(4, null)) + store.applyTimerEvent(entry(4, new Date())) + expect(store.activeTimer).toBeNull() + }) + + it('a stop for a different timer leaves the active one alone', () => { + const store = useTimeTrackingStore() + store.applyTimerEvent(entry(4, null)) + store.applyTimerEvent(entry(5, new Date())) + expect(store.activeTimer?.id).toBe(4) + }) + + it('patches a stopped entry in the loaded list', () => { + const store = useTimeTrackingStore() + store.browsedEntries = [entry(4, null), entry(5, null)] + const stopped = entry(4, new Date('2026-01-01T10:00:00Z')) + store.applyTimerEvent(stopped) + expect(store.browsedEntries.find((e: ITimeEntry) => e.id === 4)?.endTime).toEqual(stopped.endTime) + expect(store.browsedEntries).toHaveLength(2) + }) + + it('does not insert an unknown entry into the loaded list', () => { + const store = useTimeTrackingStore() + store.browsedEntries = [entry(4, null)] + store.applyTimerEvent(entry(9, new Date())) + expect(store.browsedEntries).toHaveLength(1) + expect(store.browsedEntries.find((e: ITimeEntry) => e.id === 9)).toBeUndefined() + }) + + it('hydrates the active timer scoped to the current user', async () => { + getAllMock.mockResolvedValue({items: [entry(4, null)]}) + + const store = useTimeTrackingStore() + await store.hydrateActiveTimer() + + expect(getAllMock).toHaveBeenCalledWith({ + filter: 'user_id = 7 && end_time = null', + perPage: 1, + }) + expect(store.activeTimer?.id).toBe(4) + }) + + it('clears the active timer when deleting the running entry', async () => { + removeMock.mockResolvedValue(undefined) + + const store = useTimeTrackingStore() + store.browsedEntries = [entry(4, null), entry(5, new Date())] + store.applyTimerEvent(entry(4, null)) + + await store.removeEntry(4) + + expect(removeMock).toHaveBeenCalledWith(4) + expect(store.browsedEntries.map((e: ITimeEntry) => e.id)).toEqual([5]) + expect(store.activeTimer).toBeNull() + }) + + it('applyTimerDeletion drops the entry and clears the matching active timer', () => { + const store = useTimeTrackingStore() + store.browsedEntries = [entry(4, null), entry(5, new Date())] + store.applyTimerEvent(entry(4, null)) + + store.applyTimerDeletion(4) + + expect(store.browsedEntries.map((e: ITimeEntry) => e.id)).toEqual([5]) + expect(store.activeTimer).toBeNull() + }) + + it('applyTimerDeletion of another entry leaves the active timer alone', () => { + const store = useTimeTrackingStore() + store.browsedEntries = [entry(4, null), entry(5, new Date())] + store.applyTimerEvent(entry(4, null)) + + store.applyTimerDeletion(5) + + expect(store.browsedEntries.map((e: ITimeEntry) => e.id)).toEqual([4]) + expect(store.activeTimer?.id).toBe(4) + }) +}) diff --git a/frontend/src/stores/timeTracking.ts b/frontend/src/stores/timeTracking.ts new file mode 100644 index 000000000..b384c0538 --- /dev/null +++ b/frontend/src/stores/timeTracking.ts @@ -0,0 +1,142 @@ +import {ref, computed} from 'vue' +import {acceptHMRUpdate, defineStore} from 'pinia' + +import {useWebSocket} from '@/composables/useWebSocket' +import {useTimeEntryService, parseTimeEntry} from '@/services/timeEntry' +import {useAuthStore} from '@/stores/auth' + +import type {ITimeEntry} from '@/modelTypes/ITimeEntry' + +export const useTimeTrackingStore = defineStore('timeTracking', () => { + const activeTimer = ref(null) + const browsedEntries = ref([]) + + const hasActiveTimer = computed(() => activeTimer.value !== null) + + async function browseEntries(filter: string) { + const {items} = await useTimeEntryService().getAll({ + filter, + filterTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + perPage: 250, + }) + browsedEntries.value = items + } + + // Drop a deleted entry from the list and clear the active timer if it was it. + // Shared by the local delete and the cross-tab WebSocket "timer.deleted". + function applyTimerDeletion(id: number) { + browsedEntries.value = browsedEntries.value.filter(entry => entry.id !== id) + if (activeTimer.value?.id === id) { + activeTimer.value = null + } + } + + async function removeEntry(id: number) { + await useTimeEntryService().remove(id) + applyTimerDeletion(id) + } + + // Replace an already-loaded entry in place so a stop (or any update) is + // reflected without a refetch. Never inserts — an event for an entry that + // isn't in the current filter shouldn't appear in the list. + function patchInList(entry: ITimeEntry) { + const index = browsedEntries.value.findIndex(existing => existing.id === entry.id) + if (index !== -1) { + browsedEntries.value.splice(index, 1, entry) + } + } + + // Reconcile the active timer from a timer event (WebSocket) or a local + // action: an entry with an end time is a stop — clear it if it's the one we + // track; otherwise it is the running timer. + function applyTimerEvent(entry: ITimeEntry) { + patchInList(entry) + if (entry.endTime !== null) { + if (activeTimer.value?.id === entry.id) { + activeTimer.value = null + } + return + } + activeTimer.value = entry + } + + // Source of truth on (re)connect: the caller's own running timer, if any. + async function hydrateActiveTimer() { + const userId = useAuthStore().info?.id + if (userId === undefined) { + activeTimer.value = null + return + } + + const {items} = await useTimeEntryService().getAll({ + filter: `user_id = ${userId} && end_time = null`, + perPage: 1, + }) + activeTimer.value = items[0] ?? null + } + + // Create any entry (manual, with an end time, or a running timer when end is + // omitted) and reconcile the active timer from the result. + async function createEntry(payload: Partial) { + const entry = await useTimeEntryService().create(payload) + applyTimerEvent(entry) + return entry + } + + async function updateEntry(payload: Partial & {id: number}) { + const entry = await useTimeEntryService().update(payload) + applyTimerEvent(entry) + return entry + } + + async function stopTimer() { + const entry = await useTimeEntryService().stopTimer() + applyTimerEvent(entry) + return entry + } + + let unsubscribers: Array<() => void> = [] + function subscribeToTimerEvents() { + const {subscribe} = useWebSocket() + // Ignore messages without a payload (e.g. subscribe acknowledgements). + const onEvent = (msg: {data?: unknown}) => { + if (msg.data == null) { + return + } + applyTimerEvent(parseTimeEntry(msg.data as Record)) + } + const onDelete = (msg: {data?: unknown}) => { + if (msg.data == null) { + return + } + applyTimerDeletion(parseTimeEntry(msg.data as Record).id) + } + unsubscribers.push(subscribe('timer.created', onEvent)) + unsubscribers.push(subscribe('timer.updated', onEvent)) + unsubscribers.push(subscribe('timer.deleted', onDelete)) + } + function unsubscribeFromTimerEvents() { + unsubscribers.forEach(unsubscribe => unsubscribe()) + unsubscribers = [] + } + + return { + activeTimer, + browsedEntries, + hasActiveTimer, + applyTimerEvent, + applyTimerDeletion, + hydrateActiveTimer, + browseEntries, + createEntry, + updateEntry, + stopTimer, + removeEntry, + subscribeToTimerEvents, + unsubscribeFromTimerEvents, + } +}) + +if (import.meta.hot) { + import.meta.hot.accept(acceptHMRUpdate(useTimeTrackingStore, import.meta.hot)) +} From 27bb80d11aaa84311c15ed31f7aaa7c02b19af24 Mon Sep 17 00:00:00 2001 From: kolaente Date: Mon, 8 Jun 2026 15:16:16 +0200 Subject: [PATCH 019/214] feat(input): add quick-select shortcuts to the Datepicker --- frontend/src/components/input/Datepicker.vue | 11 +- .../src/components/input/DatepickerInline.vue | 133 +++++++++--------- 2 files changed, 79 insertions(+), 65 deletions(-) diff --git a/frontend/src/components/input/Datepicker.vue b/frontend/src/components/input/Datepicker.vue index b3f55888c..2f1de652c 100644 --- a/frontend/src/components/input/Datepicker.vue +++ b/frontend/src/components/input/Datepicker.vue @@ -5,7 +5,10 @@ :disabled="disabled || undefined" @click.stop="toggleDatePopup" > - {{ date === null ? chooseDateLabel : formatDisplayDate(date) }} + {{ emptyLabel }} + @@ -16,6 +19,7 @@ > @@ -48,12 +52,17 @@ const props = withDefaults(defineProps<{ modelValue: Date | null | string, chooseDateLabel?: string, disabled?: boolean, + showShortcuts?: boolean, + // When the value is null, show this (italic) instead of chooseDateLabel. + emptyLabel?: string, }>(), { chooseDateLabel: () => { const {t} = useI18n({useScope: 'global'}) return t('input.datepicker.chooseDate') }, disabled: false, + showShortcuts: true, + emptyLabel: '', }) const emit = defineEmits<{ diff --git a/frontend/src/components/input/DatepickerInline.vue b/frontend/src/components/input/DatepickerInline.vue index 9f0467889..2245b0043 100644 --- a/frontend/src/components/input/DatepickerInline.vue +++ b/frontend/src/components/input/DatepickerInline.vue @@ -1,66 +1,68 @@