Compare commits

...

5 Commits

Author SHA1 Message Date
Gregor Vostrak
888c21369a add tailwind theme and css variables to files export, bump ui package version 2025-12-09 14:30:39 +01:00
Gregor Vostrak
89aff45cfb add direct axios dependency to package, bump package versions 2025-12-09 14:16:13 +01:00
Gregor Vostrak
569d94b240 move TimezonMismatchModal to ui package 2025-12-04 17:26:07 +01:00
Gregor Vostrak
ca94021d99 add support for window activities in the calendar view plugin 2025-12-03 18:08:00 +01:00
Gregor Vostrak
b730cc21dd move rangecalendar, popover and daterangepicker to ui package 2025-12-02 17:52:40 +01:00
35 changed files with 858 additions and 893 deletions

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { Switch } from '@/Components/ui/switch';
import { Popover, PopoverContent, PopoverTrigger } from '@/Components/ui/popover';
import { Popover, PopoverContent, PopoverTrigger } from '@/packages/ui/src';
import { Button } from '@/packages/ui/src';
import {
Select,

View File

@@ -1,19 +1,11 @@
<script setup lang="ts">
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import DialogModal from '@/packages/ui/src/DialogModal.vue';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import { onMounted, ref } from 'vue';
import { getUserTimezone } from '@/packages/ui/src/utils/settings';
import { getDayJsInstance } from '@/packages/ui/src/utils/time';
import { ref } from 'vue';
import { useForm, usePage } from '@inertiajs/vue3';
import type { User } from '@/types/models';
import { useSessionStorage } from '@vueuse/core';
import TimezoneMismatchModal from '@/packages/ui/src/TimezoneMismatchModal.vue';
const show = defineModel('show', { default: false });
const saving = defineModel('saving', { default: false });
const timezone = ref('');
const userTimezone = ref('');
const saving = ref(false);
const page = usePage<{
auth: {
@@ -21,27 +13,11 @@ const page = usePage<{
};
}>();
const hideTimezoneMismatchModal = useSessionStorage<boolean>('hide-timezone-mismatch-modal', false);
onMounted(() => {
timezone.value = Intl.DateTimeFormat().resolvedOptions().timeZone;
userTimezone.value = getUserTimezone();
const now = getDayJsInstance()();
if (
now.tz(timezone.value).format() !== now.tz(userTimezone.value).format() &&
!hideTimezoneMismatchModal.value
) {
show.value = true;
}
});
function submit() {
function handleUpdate(timezone: string) {
saving.value = true;
const form = useForm({
_method: 'PUT',
timezone: timezone.value,
timezone: timezone,
name: page.props.auth.user.name,
email: page.props.auth.user.email,
week_start: page.props.auth.user.week_start,
@@ -55,53 +31,15 @@ function submit() {
show.value = false;
location.reload();
},
onError: () => {
saving.value = false;
},
});
}
function cancel() {
show.value = false;
hideTimezoneMismatchModal.value = true;
}
</script>
<template>
<DialogModal closeable :show="show" @close="show = false">
<template #title>
<div class="flex justify-center">
<span> Timezone mismatch detected </span>
</div>
</template>
<template #content>
<div class="flex items-center space-x-4">
<div class="col-span-6 sm:col-span-4 flex-1 space-y-2">
<p>
The timezone of your device does not match the timezone in your user
settings. <br />
<strong
>We highly recommend that you update your timezone settings to your
current timezone.</strong
>
</p>
<p>
Want to change your timezone setting from
<strong>{{ userTimezone }}</strong> to <strong>{{ timezone }}</strong
>.
</p>
</div>
</div>
</template>
<template #footer>
<SecondaryButton @click="cancel"> Cancel</SecondaryButton>
<PrimaryButton
class="ms-3"
:class="{ 'opacity-25': saving }"
:disabled="saving"
@click="submit()">
Update timezone
</PrimaryButton>
</template>
</DialogModal>
<TimezoneMismatchModal v-model:show="show" :saving="saving" @update="handleUpdate" />
</template>
<style scoped></style>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { Popover, PopoverContent, PopoverTrigger } from '@/Components/ui/popover';
import { Popover, PopoverContent, PopoverTrigger } from '@/packages/ui/src';
import { Button } from '@/packages/ui/src';
import { Calendar } from '@/Components/ui/calendar';
import { CalendarIcon, XIcon } from 'lucide-vue-next';

View File

@@ -1,15 +1,16 @@
{
"name": "@solidtime/api",
"version": "0.0.5",
"version": "0.0.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@solidtime/api",
"version": "0.0.5",
"version": "0.0.6",
"license": "AGPL-3.0",
"dependencies": {
"@zodios/core": "^10.9.6",
"axios": "^1.13.2",
"typescript": "^5.5.4",
"zod": "^3.23.8"
},
@@ -1094,18 +1095,16 @@
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/axios": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.5.tgz",
"integrity": "sha512-fZu86yCo+svH3uqJ/yTdQ0QHpQu5oL+/QE+QPSv6BZSkDAoky9vytxp7u5qk83OJFS3kEBcesWni9WTZAv3tSw==",
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
"license": "MIT",
"peer": true,
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
@@ -1127,12 +1126,24 @@
"concat-map": "0.0.1"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"peer": true,
"dependencies": {
"delayed-stream": "~1.0.0"
},
@@ -1198,11 +1209,24 @@
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
@@ -1216,6 +1240,51 @@
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
@@ -1280,7 +1349,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=4.0"
},
@@ -1291,14 +1359,15 @@
}
},
"node_modules/form-data": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"peer": true,
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
@@ -1339,12 +1408,60 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@@ -1362,11 +1479,37 @@
"node": ">=8"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
@@ -1489,12 +1632,20 @@
"@jridgewell/sourcemap-codec": "^1.5.0"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.6"
}
@@ -1504,7 +1655,6 @@
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"peer": true,
"dependencies": {
"mime-db": "1.52.0"
},
@@ -1657,8 +1807,7 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/punycode": {
"version": "2.3.1",

View File

@@ -1,6 +1,6 @@
{
"name": "@solidtime/api",
"version": "0.0.5",
"version": "0.0.6",
"description": "Package containing the solidtime api client and type declarations",
"main": "./dist/solidtime-api.umd.cjs",
"module": "./dist/solidtime-api.js",
@@ -29,6 +29,7 @@
"license": "AGPL-3.0",
"dependencies": {
"@zodios/core": "^10.9.6",
"axios": "^1.13.2",
"typescript": "^5.5.4",
"zod": "^3.23.8"
},

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@solidtime/ui",
"version": "0.0.13",
"version": "0.0.15",
"description": "Package containing the solidtime ui components",
"main": "./dist/solidtime-ui-lib.umd.cjs",
"module": "./dist/solidtime-ui-lib.js",
@@ -33,7 +33,9 @@
"preview": "vite preview"
},
"files": [
"dist"
"dist",
"styles.css",
"tailwind.theme.js"
],
"keywords": [
"solidtime",

View File

@@ -712,28 +712,41 @@ onUnmounted(() => {
/* Activity status plugin styles */
.fullcalendar :deep(.activity-status-box) {
position: absolute;
width: 10px;
left: 0px;
z-index: 10;
cursor: default;
}
.fullcalendar :deep(.activity-status-box::before) {
content: '';
position: absolute;
top: 0;
bottom: 0;
width: 5px;
transition: opacity 0.2s ease;
}
.fullcalendar :deep(.activity-status-box.idle) {
background-color: rgba(156, 163, 175, 0.1) !important;
.fullcalendar :deep(.activity-status-box.idle::before) {
background-color: rgba(156, 163, 175, 0.1);
}
.fullcalendar :deep(.activity-status-box.idle):hover {
background-color: rgba(156, 163, 175, 0.5) !important;
.fullcalendar :deep(.activity-status-box.idle):hover::before {
background-color: rgba(156, 163, 175, 0.5);
}
.fullcalendar :deep(.activity-status-box.active) {
background-color: rgba(34, 197, 94, 0.3) !important;
.fullcalendar :deep(.activity-status-box.active::before) {
background-color: rgba(34, 197, 94, 0.3);
}
.fullcalendar :deep(.activity-status-box.active):hover {
background-color: rgba(34, 197, 94, 1) !important;
.fullcalendar :deep(.activity-status-box.active):hover::before {
background-color: rgba(34, 197, 94, 1);
}
/* Add left margin to events only on days with activity status data */
.fullcalendar :deep(.has-activity-status .fc-timegrid-event-harness) {
margin-left: 15px !important;
margin-left: 8px !important;
}
.fullcalendar :deep(.fc-timegrid-event) {

View File

@@ -1,42 +1,70 @@
import { createPlugin, type PluginDef } from '@fullcalendar/core';
import { computePosition, flip, shift, offset } from '@floating-ui/dom';
import { computePosition, flip, shift, offset, autoUpdate } from '@floating-ui/dom';
export interface WindowActivityInPeriod {
appName: string;
url: string | null;
count: number;
icon?: string | null;
}
export interface ActivityPeriod {
start: string;
end: string;
isIdle: boolean;
windowActivities?: WindowActivityInPeriod[];
}
export interface ActivityStatusPluginOptions {
activityPeriods?: ActivityPeriod[];
}
// Tooltip state management - single instance per module
let tooltipInstance: HTMLElement | null = null;
let cleanupAutoUpdate: (() => void) | null = null;
/**
* Creates and manages a tooltip element for activity status boxes
*/
function createTooltip(): HTMLElement {
const tooltip = document.createElement('div');
tooltip.className =
'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground';
tooltip.style.position = 'fixed';
tooltip.style.pointerEvents = 'none';
tooltip.style.opacity = '0';
tooltip.style.whiteSpace = 'nowrap';
tooltip.style.transform = 'scale(0.95)';
tooltip.style.transition = 'opacity 150ms, transform 150ms';
document.body.appendChild(tooltip);
return tooltip;
function getOrCreateTooltip(): HTMLElement {
if (!tooltipInstance) {
tooltipInstance = document.createElement('div');
tooltipInstance.className =
'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground';
tooltipInstance.style.position = 'fixed';
tooltipInstance.style.pointerEvents = 'none';
tooltipInstance.style.opacity = '0';
tooltipInstance.style.whiteSpace = 'nowrap';
tooltipInstance.style.transform = 'scale(0.95)';
tooltipInstance.style.transition = 'opacity 150ms, transform 150ms';
document.body.appendChild(tooltipInstance);
}
return tooltipInstance;
}
/**
* Shows tooltip for an activity status box
* Shows tooltip for an activity status box using Floating UI's autoUpdate
*/
function showTooltip(box: HTMLElement, tooltip: HTMLElement, text: string) {
tooltip.textContent = text;
function showTooltip(box: HTMLElement, tooltip: HTMLElement, content: string | HTMLElement) {
// Clear previous content
tooltip.innerHTML = '';
if (typeof content === 'string') {
tooltip.textContent = content;
} else {
tooltip.appendChild(content);
}
tooltip.style.opacity = '1';
tooltip.style.transform = 'scale(1)';
const updatePosition = () => {
// Clean up previous autoUpdate if it exists
if (cleanupAutoUpdate) {
cleanupAutoUpdate();
}
// Use autoUpdate to automatically update position
cleanupAutoUpdate = autoUpdate(box, tooltip, () => {
computePosition(box, tooltip, {
placement: 'right',
middleware: [offset(8), flip(), shift({ padding: 5 })],
@@ -44,17 +72,124 @@ function showTooltip(box: HTMLElement, tooltip: HTMLElement, text: string) {
tooltip.style.left = `${x}px`;
tooltip.style.top = `${y}px`;
});
};
updatePosition();
});
}
/**
* Hides the tooltip
* Hides the tooltip immediately
*/
function hideTooltip(tooltip: HTMLElement) {
tooltip.style.opacity = '0';
tooltip.style.transform = 'scale(0.95)';
// Clean up autoUpdate when tooltip is hidden
if (cleanupAutoUpdate) {
cleanupAutoUpdate();
cleanupAutoUpdate = null;
}
}
/**
* Formats duration in minutes to human readable format
*/
function formatDuration(durationMinutes: number): string {
const hours = Math.floor(durationMinutes / 60);
const minutes = durationMinutes % 60;
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
}
/**
* Creates tooltip content for an activity period
*/
function createTooltipContent(
status: string,
durationText: string,
windowActivities?: WindowActivityInPeriod[]
): string | HTMLElement {
if (!windowActivities || windowActivities.length === 0) {
return `${status} (${durationText})`;
}
const container = document.createElement('div');
container.style.maxWidth = '300px';
// Header with status and duration
const header = document.createElement('div');
header.style.fontWeight = '600';
header.style.marginBottom = '8px';
header.textContent = `${status} (${durationText})`;
container.appendChild(header);
// Window activities list
const totalActivities = windowActivities.reduce((sum, act) => sum + act.count, 0);
// Show top 5 activities
const topActivities = windowActivities.slice(0, 5);
topActivities.forEach((activity) => {
const activityDiv = document.createElement('div');
activityDiv.style.marginTop = '4px';
activityDiv.style.fontSize = '11px';
activityDiv.style.opacity = '0.9';
activityDiv.style.display = 'flex';
activityDiv.style.alignItems = 'center';
activityDiv.style.gap = '6px';
// Add icon if available
if (activity.icon) {
const icon = document.createElement('img');
icon.src = activity.icon;
icon.alt = activity.appName;
icon.style.width = '16px';
icon.style.height = '16px';
icon.style.borderRadius = '2px';
icon.style.flexShrink = '0';
activityDiv.appendChild(icon);
} else {
// Placeholder for no icon
const placeholder = document.createElement('div');
placeholder.style.width = '16px';
placeholder.style.height = '16px';
placeholder.style.borderRadius = '2px';
placeholder.style.backgroundColor = 'rgba(255, 255, 255, 0.1)';
placeholder.style.display = 'flex';
placeholder.style.alignItems = 'center';
placeholder.style.justifyContent = 'center';
placeholder.style.fontSize = '8px';
placeholder.style.flexShrink = '0';
placeholder.textContent = activity.appName.charAt(0).toUpperCase();
activityDiv.appendChild(placeholder);
}
const textSpan = document.createElement('span');
textSpan.style.flex = '1';
textSpan.style.overflow = 'hidden';
textSpan.style.textOverflow = 'ellipsis';
textSpan.style.whiteSpace = 'nowrap';
const percentage = ((activity.count / totalActivities) * 100).toFixed(0);
const activityText = activity.url
? `${activity.appName} - ${activity.url}`
: activity.appName;
textSpan.textContent = `${percentage}% ${activityText}`;
activityDiv.appendChild(textSpan);
container.appendChild(activityDiv);
});
// Show "and X more" if there are more activities
if (windowActivities.length > 5) {
const moreDiv = document.createElement('div');
moreDiv.style.marginTop = '4px';
moreDiv.style.fontSize = '11px';
moreDiv.style.opacity = '0.7';
moreDiv.style.fontStyle = 'italic';
moreDiv.textContent = `...and ${windowActivities.length - 5} more`;
container.appendChild(moreDiv);
}
return container;
}
/**
@@ -66,49 +201,32 @@ export function renderActivityStatusBoxes(
) {
if (!calendarEl) return;
// Clean up existing activity boxes and markers first
// Clean up existing activity boxes
const existingBoxes = calendarEl.querySelectorAll('.activity-status-box');
existingBoxes.forEach((box) => box.remove());
// Clean up existing tooltips
const existingTooltips = document.querySelectorAll('.activity-status-tooltip');
existingTooltips.forEach((tooltip) => tooltip.remove());
// Remove has-activity-status class from all lanes
const allLanes = calendarEl.querySelectorAll('.fc-timegrid-col');
allLanes.forEach((lane) => lane.classList.remove('has-activity-status'));
const timeGrid = calendarEl.querySelector('.fc-timegrid-body');
if (!timeGrid) {
console.log('No timegrid found');
return;
}
if (!timeGrid) return;
const lanes = timeGrid.querySelectorAll('.fc-timegrid-col');
if (lanes.length === 0) {
console.log('No lanes found');
return;
}
if (lanes.length === 0) return;
console.log(
'Rendering activity status boxes, lanes:',
lanes.length,
'periods:',
activityPeriods.length
);
// Get or reuse the single tooltip instance
const tooltip = getOrCreateTooltip();
// Create a single tooltip instance to be reused
const tooltip = createTooltip();
// Get slot duration from calendar (fallback to 15 minutes)
const slotDurationMinutes = getSlotDuration(calendarEl);
lanes.forEach((lane: Element, dayIndex: number) => {
lanes.forEach((lane: Element) => {
// Get the date for this lane from the data attribute
const laneEl = lane as HTMLElement;
const dateStr = laneEl.getAttribute('data-date');
if (!dateStr) {
console.log('No date attribute found for lane', dayIndex);
return;
}
if (!dateStr) return;
const laneDate = new Date(dateStr);
const laneDateStart = new Date(laneDate);
@@ -127,47 +245,46 @@ export function renderActivityStatusBoxes(
return;
}
// Calculate the position and height of the idle box
// Calculate actual start and end times for this day
const actualStart = periodStart > laneDateStart ? periodStart : laneDateStart;
const actualEnd = periodEnd < laneDateEnd ? periodEnd : laneDateEnd;
// Calculate the position and height of the activity box
const { top, height } = calculateBoxPosition(
calendarEl,
periodStart > laneDateStart ? periodStart : laneDateStart,
periodEnd < laneDateEnd ? periodEnd : laneDateEnd
actualStart,
actualEnd,
slotDurationMinutes
);
if (height <= 0) return;
hasActivityStatusForThisDay = true;
// Create and append the activity status box
const box = document.createElement('div');
box.className = `activity-status-box ${period.isIdle ? 'idle' : 'active'}`;
box.style.position = 'absolute';
box.style.top = `${top}px`;
box.style.height = `${height}px`;
box.style.width = '8px';
box.style.left = '4px';
box.style.right = '4px';
box.style.zIndex = '10';
box.style.cursor = 'default';
// Calculate duration in minutes
const actualStart = periodStart > laneDateStart ? periodStart : laneDateStart;
const actualEnd = periodEnd < laneDateEnd ? periodEnd : laneDateEnd;
const durationMs = actualEnd.getTime() - actualStart.getTime();
const durationMinutes = Math.round(durationMs / 60000);
// Format duration
const hours = Math.floor(durationMinutes / 60);
const minutes = durationMinutes % 60;
const durationText = hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
const durationText = formatDuration(durationMinutes);
// Add tooltip text based on status
const status = period.isIdle ? 'Idling' : 'Active';
const tooltipText = `${status} (${durationText})`;
// Create and append the activity status box
const box = document.createElement('div');
box.className = `activity-status-box ${period.isIdle ? 'idle' : 'active'}`;
box.style.top = `${top}px`;
box.style.height = `${height}px`;
// Store tooltip content generator in data attribute for event delegation
const tooltipContent = createTooltipContent(
status,
durationText,
period.windowActivities
);
// Add hover event listeners for tooltip
box.addEventListener('mouseenter', () => {
showTooltip(box, tooltip, tooltipText);
showTooltip(box, tooltip, tooltipContent);
});
box.addEventListener('mouseleave', () => {
@@ -178,8 +295,6 @@ export function renderActivityStatusBoxes(
const laneFrame = lane.querySelector('.fc-timegrid-col-frame');
if (laneFrame) {
laneFrame.appendChild(box);
} else {
console.log('No lane frame found');
}
});
@@ -190,18 +305,43 @@ export function renderActivityStatusBoxes(
});
}
/**
* Gets the slot duration from the calendar configuration
*/
function getSlotDuration(calendarEl: HTMLElement): number {
const slotsEl = calendarEl.querySelectorAll('.fc-timegrid-slot');
if (slotsEl.length < 2) return 15; // Default to 15 minutes
// Try to calculate from the time difference between slots
const firstSlot = slotsEl[0] as HTMLElement;
const secondSlot = slotsEl[1] as HTMLElement;
const firstTime = firstSlot.getAttribute('data-time');
const secondTime = secondSlot.getAttribute('data-time');
if (firstTime && secondTime) {
const [h1, m1] = firstTime.split(':').map(Number);
const [h2, m2] = secondTime.split(':').map(Number);
const diff = h2 * 60 + m2 - (h1 * 60 + m1);
if (diff > 0) return diff;
}
// Fallback to 15 minutes
return 15;
}
/**
* Calculates the pixel position and height for an activity status box
*/
function calculateBoxPosition(
calendarEl: HTMLElement,
startTime: Date,
endTime: Date
endTime: Date,
slotDurationMinutes: number
): { top: number; height: number } {
// Get the slot duration and slot height
const slotsEl = calendarEl.querySelectorAll('.fc-timegrid-slot');
if (slotsEl.length === 0) {
console.log('No slots found');
return { top: 0, height: 0 };
}
@@ -209,8 +349,6 @@ function calculateBoxPosition(
const firstSlot = slotsEl[0] as HTMLElement;
const slotHeight = firstSlot.offsetHeight;
// Each slot is 15 minutes by default (configured in TimeEntryCalendar)
const slotDurationMinutes = 15;
const pixelsPerMinute = slotHeight / slotDurationMinutes;
// Calculate start position (minutes from midnight)
@@ -224,6 +362,20 @@ function calculateBoxPosition(
return { top, height };
}
/**
* Cleanup function to remove tooltip from DOM
*/
export function cleanupActivityStatusPlugin() {
if (tooltipInstance) {
tooltipInstance.remove();
tooltipInstance = null;
}
if (cleanupAutoUpdate) {
cleanupAutoUpdate();
cleanupAutoUpdate = null;
}
}
/**
* FullCalendar plugin to display idle/active status boxes in the time grid
*/

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { Popover, PopoverContent, PopoverTrigger } from '@/Components/ui/popover';
import { Popover, PopoverContent, PopoverTrigger } from '../popover';
import Button from '../Buttons/Button.vue';
import { RangeCalendar } from '@/Components/ui/range-calendar';
import { RangeCalendar } from '../range-calendar';
import { CalendarDate } from '@internationalized/date';
import { CalendarIcon } from 'lucide-vue-next';
import { computed, ref, inject, type ComputedRef, watch } from 'vue';

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { Popover, PopoverContent, PopoverTrigger } from '@/Components/ui/popover';
import { Popover, PopoverContent, PopoverTrigger } from '../popover';
import { watch } from 'vue';
const props = withDefaults(

View File

@@ -0,0 +1,109 @@
<script setup lang="ts">
import SecondaryButton from './Buttons/SecondaryButton.vue';
import DialogModal from './DialogModal.vue';
import PrimaryButton from './Buttons/PrimaryButton.vue';
import { onMounted, ref } from 'vue';
import { getUserTimezone } from './utils/settings';
import { getDayJsInstance } from './utils/time';
import { useSessionStorage } from '@vueuse/core';
const show = defineModel('show', { default: false });
const emit = defineEmits<{
update: [timezone: string];
cancel: [];
}>();
defineProps<{
saving?: boolean;
}>();
const timezone = ref('');
const userTimezone = ref('');
const shouldShow = ref(false);
const hideTimezoneMismatchModal = useSessionStorage<boolean>('hide-timezone-mismatch-modal', false);
/**
* Check if timezone mismatch exists and should be shown
*/
function checkTimezoneMismatch(): boolean {
timezone.value = Intl.DateTimeFormat().resolvedOptions().timeZone;
userTimezone.value = getUserTimezone();
const now = getDayJsInstance()();
const hasMismatch =
now.tz(timezone.value).format() !== now.tz(userTimezone.value).format() &&
!hideTimezoneMismatchModal.value;
shouldShow.value = hasMismatch;
return hasMismatch;
}
onMounted(() => {
checkTimezoneMismatch();
if (shouldShow.value) {
show.value = true;
}
});
function submit() {
emit('update', timezone.value);
}
function cancel() {
show.value = false;
hideTimezoneMismatchModal.value = true;
emit('cancel');
}
// Expose methods for parent component
defineExpose({
checkTimezoneMismatch,
currentTimezone: timezone,
userTimezone,
});
</script>
<template>
<DialogModal closeable :show="show && shouldShow" @close="cancel">
<template #title>
<div class="flex justify-center">
<span> Timezone mismatch detected </span>
</div>
</template>
<template #content>
<div class="flex items-center space-x-4">
<div class="col-span-6 sm:col-span-4 flex-1 space-y-2">
<p>
The timezone of your device does not match the timezone in your user
settings. <br />
<strong
>We highly recommend that you update your timezone settings to your
current timezone.</strong
>
</p>
<p>
Want to change your timezone setting from
<strong>{{ userTimezone }}</strong> to <strong>{{ timezone }}</strong
>.
</p>
</div>
</div>
</template>
<template #footer>
<SecondaryButton @click="cancel"> Cancel</SecondaryButton>
<PrimaryButton
class="ms-3"
:class="{ 'opacity-25': saving }"
:disabled="saving"
@click="submit()">
Update timezone
</PrimaryButton>
</template>
</DialogModal>
</template>
<style scoped></style>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { cn } from '@/lib/utils';
import { cn } from '../utils/cn';
import { AccordionContent, type AccordionContentProps } from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { cn } from '@/lib/utils';
import { cn } from '../utils/cn';
import { AccordionItem, type AccordionItemProps, useForwardProps } from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { cn } from '@/lib/utils';
import { cn } from '../utils/cn';
import { ChevronDown } from 'lucide-vue-next';
import { AccordionHeader, AccordionTrigger, type AccordionTriggerProps } from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';

View File

@@ -37,7 +37,12 @@ import MoreOptionsDropdown from './MoreOptionsDropdown.vue';
import FullCalendarEventContent from './FullCalendar/FullCalendarEventContent.vue';
import FullCalendarDayHeader from './FullCalendar/FullCalendarDayHeader.vue';
import TimeEntryCalendar from './FullCalendar/TimeEntryCalendar.vue';
import DateRangePicker from './Input/DateRangePicker.vue';
import TimezoneMismatchModal from './TimezoneMismatchModal.vue';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip/index';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from './accordion/index';
import { Popover, PopoverContent, PopoverTrigger, PopoverAnchor } from './popover/index';
import { RangeCalendar } from './range-calendar/index';
export type { ActivityPeriod } from './FullCalendar/idleStatusPlugin';
export {
@@ -69,8 +74,19 @@ export {
FullCalendarEventContent,
FullCalendarDayHeader,
TimeEntryCalendar,
DateRangePicker,
TimezoneMismatchModal,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
Popover,
PopoverContent,
PopoverTrigger,
PopoverAnchor,
RangeCalendar,
};