diff --git a/AGENTS.md b/AGENTS.md index 2f8f5b44..b0d7b0e7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,6 +21,7 @@ - **Component discovery**: inspect `src/components`, exported barrels (`src/components/**/index.ts`), and `@nside/wefa` type definitions before crafting new UI. Use `npm info @nside/wefa` or the local `dist/lib.d.ts` for quick lookup. - **PrimeVue composition**: when WeFa lacks a widget, compose PrimeVue primitives with Tailwind classes. Imports follow `import Component from 'primevue/component'` and styling sticks to utility classes or theme tokens (`bg-primary-500`, `text-surface-900`, etc.). - **State & data utilities**: rely on provided composables (`src/composables`), Pinia stores (`src/stores`), and network helpers (`src/network`). Prefer enhancing these layers rather than re-implementing ad-hoc logic inside components. +- **Props definition**: use `const { prop1 = prop1Default, ... } = defineProps()` with explicit interfaces/types. Avoid the usage of `withDefault()`. - **Stories & docs**: every component lives with `.stories.ts`, `.mdx`, and spec files under its folder. Keep stories authoritative and update MDX docs when props/state change. - **Quality gates**: run `npm run lint-check`, `npm run format-check`, `npm run test:unit`, `npm run test:e2e` (when flows/routing change), `npm run build`, and regenerate Storybook (`npm run storybook` or `npm run build-storybook`) before handing work off. diff --git a/vue/eslint.config.ts b/vue/eslint.config.ts index d7b33433..212e7bb7 100644 --- a/vue/eslint.config.ts +++ b/vue/eslint.config.ts @@ -46,7 +46,8 @@ export default defineConfigWithVueTs( "order": [ "template", "script", "style" ] // Enforce this precise order in component definition! }], "vitest/prefer-called-exactly-once-with": "off", // Exactly once is not always possible - 'sonarjs/todo-tag': 'warn' // TODOs can refer to issues that are not yet fixed + "sonarjs/todo-tag": "warn", // TODOs can refer to issues that are not yet fixed + "security/detect-object-injection": "off" // False positives are common with this rule in TS projects } }, { diff --git a/vue/knip.json b/vue/knip.json index 266cb728..ae348bae 100644 --- a/vue/knip.json +++ b/vue/knip.json @@ -8,5 +8,9 @@ "ignoreDependencies": ["@tailwindcss/cli", "@vee-validate/zod"], "tags": [ "-lintignore" - ] + ], + "storybook": { + "config": [".storybook/main.ts"], + "entry": [".storybook/preview.ts", "src/**/*.stories.@(js|jsx|ts|tsx)"] + } } diff --git a/vue/package-lock.json b/vue/package-lock.json index 248bf30d..322fedd1 100644 --- a/vue/package-lock.json +++ b/vue/package-lock.json @@ -15,7 +15,9 @@ "@tailwindcss/vite": "^4.1.18", "@tanstack/vue-query": "^5.92.8", "@vee-validate/zod": "^4.15.1", + "@vueuse/core": "^14.1.0", "axios": "^1.13.2", + "luxon": "^3.7.2", "marked": "^17.0.1", "pinia": "^3.0.4", "plotly.js-dist-min": "^3.3.1", @@ -42,6 +44,7 @@ "@tsconfig/node22": "^22.0.5", "@types/eslint-plugin-security": "^3.0.0", "@types/jsdom": "^27.0.0", + "@types/luxon": "^3.7.1", "@types/node": "^25.0.9", "@types/plotly.js-dist-min": "^2.3.4", "@vitejs/plugin-vue": "^6.0.3", @@ -4050,6 +4053,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/luxon": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.1.tgz", + "integrity": "sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mdx": { "version": "2.0.13", "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.13.tgz", @@ -4102,6 +4112,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.44.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.44.0.tgz", @@ -5331,6 +5347,44 @@ } } }, + "node_modules/@vueuse/core": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.1.0.tgz", + "integrity": "sha512-rgBinKs07hAYyPF834mDTigH7BtPqvZ3Pryuzt1SD/lg5wEcWqvwzXXYGEDb2/cP0Sj5zSvHl3WkmMELr5kfWw==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "14.1.0", + "@vueuse/shared": "14.1.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@vueuse/metadata": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.1.0.tgz", + "integrity": "sha512-7hK4g015rWn2PhKcZ99NyT+ZD9sbwm7SGvp7k+k+rKGWnLjS/oQozoIZzWfCewSUeBmnJkIb+CNr7Zc/EyRnnA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.1.0.tgz", + "integrity": "sha512-EcKxtYvn6gx1F8z9J5/rsg3+lTQnvOruQd8fUecW99DCK04BkWD7z5KQ/wTAx+DazyoEE9dJt/zV8OIEQbM6kw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, "node_modules/abbrev": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", @@ -8679,6 +8733,15 @@ "yallist": "^3.0.2" } }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", diff --git a/vue/package.json b/vue/package.json index 7ecc47fa..d8b06b06 100644 --- a/vue/package.json +++ b/vue/package.json @@ -72,7 +72,9 @@ "@tailwindcss/vite": "^4.1.18", "@tanstack/vue-query": "^5.92.8", "@vee-validate/zod": "^4.15.1", + "@vueuse/core": "^14.1.0", "axios": "^1.13.2", + "luxon": "^3.7.2", "marked": "^17.0.1", "pinia": "^3.0.4", "plotly.js-dist-min": "^3.3.1", @@ -96,6 +98,7 @@ "@tsconfig/node22": "^22.0.5", "@types/eslint-plugin-security": "^3.0.0", "@types/jsdom": "^27.0.0", + "@types/luxon": "^3.7.1", "@types/node": "^25.0.9", "@types/plotly.js-dist-min": "^2.3.4", "@vitejs/plugin-vue": "^6.0.3", diff --git a/vue/src/components/GanttChartComponent/GanttChartComponent.mdx b/vue/src/components/GanttChartComponent/GanttChartComponent.mdx new file mode 100644 index 00000000..15c58645 --- /dev/null +++ b/vue/src/components/GanttChartComponent/GanttChartComponent.mdx @@ -0,0 +1,143 @@ +import { Meta, Canvas, ArgTypes } from '@storybook/addon-docs/blocks' +import * as GanttChartStories from './GanttChartComponent.stories' + + + +# Gantt Chart + +A flexible, virtualized Gantt chart that supports daily and weekly views, layered activity types, and translation-ready labels. + +## Overview + +The GanttChart component renders a calendar grid with month and week headers, and a virtualized list of rows. Activities can be rendered as stripes, primary bars, or compact stacked minis. Weekly view collapses the grid to week columns and expands month headers across overlapping weeks. + +### Key Features + +- **Daily & Weekly Views**: Toggle with `viewMode`. +- **Layered Activities**: Stripe, bar, and mini styles with overlap rules. +- **Stacked Minis**: Overlapping minis grow row height automatically. +- **Virtualized Rows**: Smooth scrolling for large datasets. +- **Translation Ready**: All user-facing labels are passed through `t()`. +- **Configurable Sidebar**: Adjust the label column width via `leftHeaderWidthPx`. + +## Basic Usage + + + +```vue + + + +``` + +## Weekly View + +```vue + +``` + +- Weekly view renders a single week cell per column. +- Activities fill any week they overlap (inclusive by week). +- Month headers can span overlapping weeks. + +## Activity Types + +Activities support a `visualType`: + +- `stripe`: diagonal background context +- `bar`: primary rounded bar +- `mini`: compact bar, stacked into lanes when overlapping + +## Tooltips and Clicks + +```vue + +``` + +## API Reference + + + +```ts +export type GanttChartActivityData = { + id?: string | number + label?: string + startDate: Date + endDate: Date + visualType?: 'stripe' | 'bar' | 'mini' + color?: string + colorClass?: string +} + +export type GanttChartRowData = { + id?: string | number + label?: string + header?: string + activities: GanttChartActivityData[] +} + +export type GanttChartLinkData = { + id?: string | number + fromId: string | number + toId: string | number + type?: 'finish-start' | 'start-start' + color?: string +} +``` + +## Translation Notes + +- `headerLabel`, `row.header`, `row.label`, and `activity.label` are treated as translation keys. +- Week labels use the key `gantt_chart.week` by default. diff --git a/vue/src/components/GanttChartComponent/GanttChartComponent.spec.ts b/vue/src/components/GanttChartComponent/GanttChartComponent.spec.ts new file mode 100644 index 00000000..a87d03e5 --- /dev/null +++ b/vue/src/components/GanttChartComponent/GanttChartComponent.spec.ts @@ -0,0 +1,257 @@ +import { describe, it, expect, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import GanttChartComponent from './GanttChartComponent.vue' +import GanttChartRowGrid from './GanttChartRowGrid.vue' +import { BASE_ROW_HEIGHT_PX, MINI_GAP_PX, MINI_HEIGHT_PX, getWeekColumns } from './ganttChartLayout' +import type { GanttChartRowData } from './ganttChartTypes' + +vi.mock('@/locales', () => ({ + useI18nLib: () => ({ t: (key: string) => key }), +})) + +vi.mock('@vueuse/core', async () => { + const vue = await import('vue') + return { + useResizeObserver: () => { + /* no-op for tests */ + }, + useVirtualList: (source: unknown) => { + const list = vue.computed(() => { + const value = vue.unref(source) ?? [] + return (value as Array).map((data, index) => ({ data, index })) + }) + return { + list, + containerProps: { + ref: vue.ref(null), + onScroll: () => { + /* no-op */ + }, + style: {}, + }, + wrapperProps: vue.computed(() => ({ + style: { width: '100%', height: '100%', marginTop: '0px' }, + })), + } + }, + } +}) + +const baseRows: GanttChartRowData[] = [ + { + id: 1, + label: 'Row 1', + header: 'Line A', + activities: [ + { + id: 'bar-1', + label: 'Optimized', + startDate: new Date(2026, 0, 3), + endDate: new Date(2026, 0, 5), + visualType: 'bar', + colorClass: 'bg-emerald-400/80', + }, + ], + }, + { + id: 2, + label: 'Row 2', + header: 'Line B', + activities: [ + { + id: 'bar-2', + label: 'Optimized', + startDate: new Date(2026, 0, 5), + endDate: new Date(2026, 0, 7), + visualType: 'bar', + colorClass: 'bg-emerald-400/80', + }, + ], + }, +] + +describe('GanttChartComponent', () => { + it('renders top-left header label and row headers', () => { + const wrapper = mount(GanttChartComponent, { + props: { + startDate: new Date(2026, 0, 1), + endDate: new Date(2026, 0, 7), + rows: baseRows, + headerLabel: 'Line', + }, + global: { + directives: { + tooltip: () => { + /* no-op */ + }, + }, + }, + }) + + expect(wrapper.text()).toContain('Line') + expect(wrapper.text()).toContain('Line A') + }) + + it('emits activityClick with activity and row data', async () => { + const wrapper = mount(GanttChartComponent, { + props: { + startDate: new Date(2026, 0, 1), + endDate: new Date(2026, 0, 7), + rows: baseRows, + }, + global: { + directives: { + tooltip: () => { + /* no-op */ + }, + }, + }, + }) + + await wrapper.find('span').trigger('click') + + const emitted = wrapper.emitted('activityClick') as unknown[] | undefined + expect(emitted).toBeTruthy() + const payload = emitted?.[0] as Array<{ label?: string; id?: number }> + expect(payload?.[0]?.label).toBe('Optimized') + expect(payload?.[1]?.id).toBe(1) + }) + + it('renders link paths when links are provided', () => { + const wrapper = mount(GanttChartComponent, { + props: { + startDate: new Date(2026, 0, 1), + endDate: new Date(2026, 0, 7), + rows: baseRows, + links: [{ fromId: 'bar-1', toId: 'bar-2' }], + }, + global: { + directives: { + tooltip: () => { + /* no-op */ + }, + }, + }, + }) + + expect(wrapper.find('[data-link-id="bar-1-bar-2"]').exists()).toBe(true) + }) + + it('renders weekly headers with month spans across overlapping weeks', () => { + const start = new Date(2026, 0, 25) + const end = new Date(2026, 1, 10) + const expectedWeeks = getWeekColumns(start, end).length + + const wrapper = mount(GanttChartComponent, { + props: { + startDate: start, + endDate: end, + rows: baseRows, + viewMode: 'week', + }, + global: { + directives: { + tooltip: () => { + /* no-op */ + }, + }, + }, + }) + + const weekLabels = wrapper.findAll('[aria-label^="gantt_chart.week "]') + expect(weekLabels.length).toBe(expectedWeeks) + expect(wrapper.text()).toContain('Jan 2026') + expect(wrapper.text()).toContain('Feb 2026') + }) + + it('hides day headers in weekly view', () => { + const startDate = new Date(2026, 0, 1) + const endDate = new Date(2026, 0, 7) + const wrapper = mount(GanttChartComponent, { + props: { + startDate, + endDate, + rows: baseRows, + viewMode: 'week', + }, + global: { + directives: { + tooltip: () => { + /* no-op */ + }, + }, + }, + }) + + expect(wrapper.html()).not.toContain('Thu Jan 01 2026') + }) + + it('stacks mini activities by week when in weekly view', () => { + const startDate = new Date(2026, 0, 1) + const endDate = new Date(2026, 0, 7) + const miniRows: GanttChartRowData[] = [ + { + id: 1, + header: 'Line A', + activities: [ + { + id: 'mini-1', + label: 'Mini', + startDate: new Date(2026, 0, 1), + endDate: new Date(2026, 0, 2), + visualType: 'mini', + colorClass: 'bg-amber-400/80', + }, + { + id: 'mini-2', + label: 'Mini', + startDate: new Date(2026, 0, 3), + endDate: new Date(2026, 0, 4), + visualType: 'mini', + colorClass: 'bg-amber-400/80', + }, + ], + }, + ] + + const dayWrapper = mount(GanttChartComponent, { + props: { + startDate, + endDate, + rows: miniRows, + viewMode: 'day', + stackMiniActivities: true, + }, + global: { + directives: { + tooltip: () => { + /* no-op */ + }, + }, + }, + }) + const weekWrapper = mount(GanttChartComponent, { + props: { + startDate, + endDate, + rows: miniRows, + viewMode: 'week', + stackMiniActivities: true, + }, + global: { + directives: { + tooltip: () => { + /* no-op */ + }, + }, + }, + }) + + const [dayRow] = dayWrapper.findAllComponents(GanttChartRowGrid) + const [weekRow] = weekWrapper.findAllComponents(GanttChartRowGrid) + + expect(dayRow?.attributes('style')).toContain(`height: ${BASE_ROW_HEIGHT_PX}px`) + const expectedWeekHeight = BASE_ROW_HEIGHT_PX + MINI_HEIGHT_PX + MINI_GAP_PX + expect(weekRow?.attributes('style')).toContain(`height: ${expectedWeekHeight}px`) + }) +}) diff --git a/vue/src/components/GanttChartComponent/GanttChartComponent.stories.ts b/vue/src/components/GanttChartComponent/GanttChartComponent.stories.ts new file mode 100644 index 00000000..0ae57217 --- /dev/null +++ b/vue/src/components/GanttChartComponent/GanttChartComponent.stories.ts @@ -0,0 +1,253 @@ +import { type Meta, type StoryObj } from '@storybook/vue3-vite' +import { expect, within } from 'storybook/test' +import { ref } from 'vue' +import Button from 'primevue/button' + +import GanttChartComponent from './GanttChartComponent.vue' +import type { GanttChartActivityData, GanttChartRowData } from './ganttChartTypes' + +const buildRows = (count: number): GanttChartRowData[] => { + return Array.from({ length: count }, (_, index) => { + const startDay = (index * 3) % 28 + const baseDate = new Date(2026, 0, 1 + startDay) + const rowId = `row-${index}` + const baseActivities: GanttChartActivityData[] = [ + { + id: `planned-${index}`, + label: 'Planned', + startDate: new Date(baseDate), + endDate: new Date(2026, 0, 6 + startDay), + visualType: 'stripe', + color: 'rgba(59, 130, 246, 0.2)', + }, + { + id: `optimized-${index}`, + label: 'Optimized', + startDate: new Date(2026, 0, 3 + startDay), + endDate: new Date(2026, 0, 9 + startDay), + visualType: 'bar', + colorClass: 'bg-emerald-400/80', + }, + { + id: `desired-${index}`, + label: 'Desired', + startDate: new Date(2026, 0, 5 + startDay), + endDate: new Date(2026, 0, 12 + startDay), + visualType: 'mini', + colorClass: 'bg-amber-400/80', + }, + ] + const extraActivities: GanttChartActivityData[] = + index % 3 === 0 + ? [ + { + id: `desired-2-${index}`, + label: 'Alt', + startDate: new Date(2026, 0, 7 + startDay), + endDate: new Date(2026, 0, 10 + startDay), + visualType: 'mini', + colorClass: 'bg-amber-500/80', + }, + ] + : [] + const forwardMiniActivities: GanttChartActivityData[] = + index % 4 === 0 + ? [ + { + id: `desired-forward-${index}`, + label: 'Follow-up', + startDate: new Date(2026, 0, 14 + startDay), + endDate: new Date(2026, 0, 16 + startDay), + visualType: 'mini', + colorClass: 'bg-amber-300/80', + }, + ] + : [] + + return { + id: rowId, + label: `Row ${index + 1}`, + header: `Line ${index + 1}`, + activities: [...baseActivities, ...extraActivities, ...forwardMiniActivities], + } + }) +} + +const buildLinks = (rows: GanttChartRowData[]) => { + const links = [] + for (let index = 0; index < rows.length - 1; index += 4) { + const from = rows[index]?.activities.find((activity) => activity.visualType === 'bar') + const to = rows[index + 1]?.activities.find((activity) => activity.visualType === 'bar') + if (from?.id && to?.id) { + links.push({ fromId: from.id, toId: to.id }) + } + } + for (let index = 0; index < rows.length - 2; index += 5) { + const from = rows[index]?.activities.find((activity) => activity.visualType === 'mini') + const to = rows[index + 2]?.activities.find((activity) => activity.visualType === 'mini') + if (from?.id && to?.id) { + links.push({ fromId: from.id, toId: to.id }) + } + } + rows.forEach((row) => { + const miniActivities = row.activities + .filter((activity) => activity.visualType === 'mini') + .sort((a, b) => a.startDate.getTime() - b.startDate.getTime()) + if (miniActivities.length < 2) { + return + } + + const [first, second] = miniActivities + if (first?.id && second?.id && first.endDate <= second.startDate) { + links.push({ fromId: first.id, toId: second.id }) + } + }) + return links +} +const meta: Meta = { + title: 'Components/GanttChart', + component: GanttChartComponent, + args: { + startDate: new Date(2026, 0, 1), + endDate: new Date(2026, 1, 28), + rows: buildRows(40), + links: buildLinks(buildRows(40)), + headerLabel: 'gantt_chart.header', + viewMode: 'day', + showWeekendShading: true, + stackMiniActivities: true, + leftHeaderWidthPx: 320, + }, + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: ` +The GanttChartComponent renders a yearly calendar grid with month, week, and day headers. +Rows are virtualized so large datasets remain scrollable without performance issues. + +The story wraps the component in a fixed-height container to demonstrate scrolling in both axes. + `, + }, + }, + }, + argTypes: { + viewMode: { + control: { type: 'inline-radio' }, + options: ['day', 'week'], + }, + showWeekendShading: { + control: { type: 'boolean' }, + }, + stackMiniActivities: { + control: { type: 'boolean' }, + }, + startDate: { + control: { type: 'date' }, + }, + endDate: { + control: { type: 'date' }, + }, + rows: { + control: { type: 'object' }, + }, + links: { + control: { type: 'object' }, + }, + headerLabel: { + control: { type: 'text' }, + }, + leftHeaderWidthPx: { + control: { type: 'number' }, + }, + activityTooltip: { + control: false, + }, + activityClick: { + control: false, + }, + }, +} + +export default meta + +type Story = StoryObj + +export const Default: Story = { + render: (args) => ({ + components: { GanttChartComponent }, + setup() { + return { args } + }, + template: ` +
+ +
+ `, + }), + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement) + await expect( + canvas.getByText((text) => text === 'Header' || text === (args.headerLabel ?? 'Header')) + ).toBeInTheDocument() + }, +} + +export const CustomTooltipAndClick: Story = { + name: 'Custom Tooltip + Click', + render: (args) => ({ + components: { GanttChartComponent }, + setup() { + const lastClick = ref('Click an activity') + type Row = GanttChartRowData + type Activity = GanttChartActivityData + const activityTooltip = (activity: Activity, row: Row) => + `${row.header ?? row.label}: ${activity.label ?? 'gantt_chart.activity'}` + const activityClick = (activity: Activity, row: Row) => { + lastClick.value = `${row.header ?? row.label}: ${activity.label ?? 'gantt_chart.activity'}` + } + + return { args, lastClick, activityTooltip, activityClick } + }, + template: ` +
+
Last click: {{ lastClick }}
+ +
+ `, + }), +} + +export const ToggleViewMode: Story = { + render: (args) => ({ + components: { GanttChartComponent, Button }, + setup() { + const viewMode = ref<'day' | 'week'>(args.viewMode ?? 'day') + + return { args, viewMode } + }, + template: ` +
+
+
+ +
+ `, + }), +} diff --git a/vue/src/components/GanttChartComponent/GanttChartComponent.vue b/vue/src/components/GanttChartComponent/GanttChartComponent.vue new file mode 100644 index 00000000..038f0b67 --- /dev/null +++ b/vue/src/components/GanttChartComponent/GanttChartComponent.vue @@ -0,0 +1,220 @@ + + + diff --git a/vue/src/components/GanttChartComponent/GanttChartGrid.vue b/vue/src/components/GanttChartComponent/GanttChartGrid.vue new file mode 100644 index 00000000..b64a097a --- /dev/null +++ b/vue/src/components/GanttChartComponent/GanttChartGrid.vue @@ -0,0 +1,66 @@ + + + diff --git a/vue/src/components/GanttChartComponent/GanttChartHeaderGrid.vue b/vue/src/components/GanttChartComponent/GanttChartHeaderGrid.vue new file mode 100644 index 00000000..525d82fa --- /dev/null +++ b/vue/src/components/GanttChartComponent/GanttChartHeaderGrid.vue @@ -0,0 +1,167 @@ + + + diff --git a/vue/src/components/GanttChartComponent/GanttChartHeaderLabel.vue b/vue/src/components/GanttChartComponent/GanttChartHeaderLabel.vue new file mode 100644 index 00000000..5d3e3a99 --- /dev/null +++ b/vue/src/components/GanttChartComponent/GanttChartHeaderLabel.vue @@ -0,0 +1,23 @@ + + + diff --git a/vue/src/components/GanttChartComponent/GanttChartLinksOverlay.vue b/vue/src/components/GanttChartComponent/GanttChartLinksOverlay.vue new file mode 100644 index 00000000..7239cd6a --- /dev/null +++ b/vue/src/components/GanttChartComponent/GanttChartLinksOverlay.vue @@ -0,0 +1,105 @@ + + + diff --git a/vue/src/components/GanttChartComponent/GanttChartRowGrid.vue b/vue/src/components/GanttChartComponent/GanttChartRowGrid.vue new file mode 100644 index 00000000..f178c0af --- /dev/null +++ b/vue/src/components/GanttChartComponent/GanttChartRowGrid.vue @@ -0,0 +1,288 @@ + + + diff --git a/vue/src/components/GanttChartComponent/GanttChartRowLabel.vue b/vue/src/components/GanttChartComponent/GanttChartRowLabel.vue new file mode 100644 index 00000000..a3f17061 --- /dev/null +++ b/vue/src/components/GanttChartComponent/GanttChartRowLabel.vue @@ -0,0 +1,32 @@ + + + diff --git a/vue/src/components/GanttChartComponent/composables/useGanttLinks.ts b/vue/src/components/GanttChartComponent/composables/useGanttLinks.ts new file mode 100644 index 00000000..ffc98fbe --- /dev/null +++ b/vue/src/components/GanttChartComponent/composables/useGanttLinks.ts @@ -0,0 +1,212 @@ +import { computed, type Ref } from 'vue' +import { + BAR_VERTICAL_PADDING_PX, + BASE_ROW_HEIGHT_PX, + MINI_GAP_PX, + MINI_HEIGHT_PX, + computeMiniLanes, + getActivitySpanPx, + type WeekColumn, +} from '@/components/GanttChartComponent/ganttChartLayout' +import type { + GanttChartActivityData, + GanttChartLinkData, + GanttChartRowData, +} from '@/components/GanttChartComponent/ganttChartTypes' + +type GanttListItem = { data: GanttChartRowData; index: number } + +export interface GanttLinkLayer { + id: string | number + path: string + color: string + layer: 'base' | 'mini' +} + +// Composable for computing and managing Gantt chart links between activities. +export const useGanttLinks = ({ + list, + rowHeights, + rowOffsets, + dateRange, + viewMode, + columnWidthPx, + weekColumns, + links, + stackMiniActivities, +}: { + list: Ref + rowHeights: Ref + rowOffsets: Ref + dateRange: Ref + viewMode: Ref<'day' | 'week'> + columnWidthPx: Ref + weekColumns: Ref + links: Ref + stackMiniActivities: Ref +}) => { + // Computes the vertical offset in pixels of the virtual list. + const virtualOffsetPx = computed(() => { + const firstVisibleIndex = list.value[0]?.index + if (firstVisibleIndex === undefined) { + return 0 + } + + return rowOffsets.value[firstVisibleIndex] ?? 0 + }) + + // Computes the positions of visible activities in the Gantt chart. + const visibleActivityPositions = computed(() => { + const positions = new Map< + string | number, + { startX: number; endX: number; y: number; visualType: GanttChartActivityData['visualType'] } + >() + + for (const item of list.value) { + const rowIndex = item.index + const row = item.data + const rowHeight = rowHeights.value[rowIndex] ?? BASE_ROW_HEIGHT_PX + const rowTop = (rowOffsets.value[rowIndex] ?? 0) - virtualOffsetPx.value + const miniLayout = computeMiniLanes(row.activities, stackMiniActivities.value, viewMode.value) + + for (const activity of row.activities) { + if (activity.id === undefined || activity.id === null) { + continue + } + + const span = getActivitySpanPx( + activity, + dateRange.value, + viewMode.value, + columnWidthPx.value, + weekColumns.value + ) + const startX = span.left + const endX = span.left + span.width + let y = rowTop + rowHeight / 2 + + if (activity.visualType === 'mini') { + const lane = miniLayout.lanes.find((item) => item.activity === activity)?.laneIndex ?? 0 + const laneCount = miniLayout.laneCount + const stackHeight = laneCount * MINI_HEIGHT_PX + (laneCount - 1) * MINI_GAP_PX + const topStart = Math.max(BAR_VERTICAL_PADDING_PX, (rowHeight - stackHeight) / 2) + y = rowTop + topStart + lane * (MINI_HEIGHT_PX + MINI_GAP_PX) + MINI_HEIGHT_PX / 2 + } + + positions.set(activity.id, { startX, endX, y, visualType: activity.visualType }) + } + } + + return positions + }) + + // Generates a rounded SVG path from a series of points. Used for link paths' corners. + const roundedPath = (points: Array<{ x: number; y: number }>, radius: number) => { + if (points.length < 2) { + return '' + } + + let d = `M ${points[0]!.x} ${points[0]!.y}` + + for (let i = 1; i < points.length; i += 1) { + const prev = points[i - 1]! + const curr = points[i]! + const next = points[i + 1] + if (!next) { + d += ` L ${curr.x} ${curr.y}` + continue + } + + const v1x = curr.x - prev.x + const v1y = curr.y - prev.y + const v2x = next.x - curr.x + const v2y = next.y - curr.y + const isCorner = (v1x !== 0 && v2y !== 0) || (v1y !== 0 && v2x !== 0) + + if (!isCorner) { + d += ` L ${curr.x} ${curr.y}` + continue + } + + const len1 = Math.abs(v1x) + Math.abs(v1y) + const len2 = Math.abs(v2x) + Math.abs(v2y) + const r = Math.min(radius, len1 / 2, len2 / 2) + const p1x = curr.x - Math.sign(v1x) * r + const p1y = curr.y - Math.sign(v1y) * r + const p2x = curr.x + Math.sign(v2x) * r + const p2y = curr.y + Math.sign(v2y) * r + + d += ` L ${p1x} ${p1y} Q ${curr.x} ${curr.y} ${p2x} ${p2y}` + } + + return d + } + + // Computes the SVG path data for each link between activities. + const linkPaths = computed(() => { + const paths: GanttLinkLayer[] = [] + + links.value.forEach((link) => { + const from = visibleActivityPositions.value.get(link.fromId) + const to = visibleActivityPositions.value.get(link.toId) + if (!from || !to) { + return + } + + const startX = link.type === 'start-start' ? from.startX : from.endX + const endX = to.startX + const isBackward = endX < startX + const gap = Math.abs(endX - startX) + const bend = Math.min(28, Math.max(10, gap / 2)) + const startOutX = startX + bend + const endInX = endX - bend + const midY = from.y + (to.y - from.y) / 2 + const points: Array<{ x: number; y: number }> = [] + + if (isBackward) { + const loopX = startX + bend + 16 + points.push( + { x: startX, y: from.y }, + { x: loopX, y: from.y }, + { x: loopX, y: midY }, + { x: endInX, y: midY }, + { x: endInX, y: to.y }, + { x: endX, y: to.y } + ) + } else { + points.push( + { x: startX, y: from.y }, + { x: startOutX, y: from.y }, + { x: startOutX, y: midY }, + { x: endInX, y: midY }, + { x: endInX, y: to.y }, + { x: endX, y: to.y } + ) + } + + const path = roundedPath(points, 6) + const isMiniLink = from.visualType === 'mini' || to.visualType === 'mini' + + paths.push({ + id: link.id ?? `${link.fromId}-${link.toId}`, + path, + color: link.color ?? 'rgba(100, 116, 139, 0.8)', + layer: isMiniLink ? 'mini' : 'base', + }) + }) + + return paths + }) + + // Separates link paths into base and mini layers for rendering order. + const linkLayers = computed(() => ({ + base: linkPaths.value.filter((link) => link.layer === 'base'), + mini: linkPaths.value.filter((link) => link.layer === 'mini'), + })) + + return { + virtualOffsetPx, + visibleActivityPositions, + linkLayers, + } +} diff --git a/vue/src/components/GanttChartComponent/composables/useGanttSizing.ts b/vue/src/components/GanttChartComponent/composables/useGanttSizing.ts new file mode 100644 index 00000000..39fee489 --- /dev/null +++ b/vue/src/components/GanttChartComponent/composables/useGanttSizing.ts @@ -0,0 +1,42 @@ +import { computed, onMounted, ref, type Ref } from 'vue' +import { useResizeObserver } from '@vueuse/core' + +// Composable to manage Gantt chart sizing and layout. +export const useGanttSizing = ( + container: Ref, + header: Ref +) => { + const containerHeight = ref(0) + const headerHeight = ref(0) + + // Observe size changes of the container and header elements. + useResizeObserver(container, (entries) => { + const entry = entries[0] + const { height } = entry!.contentRect + containerHeight.value = height + }) + useResizeObserver(header, (entries) => { + const entry = entries[0] + const { height } = entry!.contentRect + headerHeight.value = height + }) + + onMounted(() => { + containerHeight.value = container.value?.offsetHeight ?? 0 + headerHeight.value = header.value?.offsetHeight ?? 0 + }) + + // Compute the body height in pixels (container height minus header height). + const bodyHeightPx = computed(() => Math.max(0, containerHeight.value - headerHeight.value)) + // Compute the card style with dynamic height. + const cardStyle = computed(() => ({ + height: `${containerHeight.value}px`, + })) + + return { + containerHeight, + headerHeight, + bodyHeightPx, + cardStyle, + } +} diff --git a/vue/src/components/GanttChartComponent/ganttChartLayout.ts b/vue/src/components/GanttChartComponent/ganttChartLayout.ts new file mode 100644 index 00000000..9826e0b1 --- /dev/null +++ b/vue/src/components/GanttChartComponent/ganttChartLayout.ts @@ -0,0 +1,211 @@ +import { DateTime } from 'luxon' + +export type GanttChartActivityType = 'stripe' | 'bar' | 'mini' +export type GanttChartViewMode = 'day' | 'week' + +export type GanttChartActivityLike = { + startDate: Date + endDate: Date + visualType?: GanttChartActivityType +} + +export type MiniLaneItem = { + activity: T + laneIndex: number +} + +export type WeekColumn = { + start: Date + end: Date + weekYear: number + weekNumber: number +} + +export type MonthSpan = { + year: number + month: number + label: string + startIndex: number + endIndex: number +} + +// Pixel width for a single day column in day view. +export const DAY_CELL_WIDTH_PX = 40 +// Pixel width for a single week column in week view. +export const WEEK_CELL_WIDTH_PX = 40 +// Default row height before mini stacking expands it. +export const BASE_ROW_HEIGHT_PX = 30 +// Vertical padding to keep bar activities off row edges. +export const BAR_VERTICAL_PADDING_PX = 4 +// Height of mini activities (stacked lanes). +export const MINI_HEIGHT_PX = 12 +// Gap between stacked mini activity lanes. +export const MINI_GAP_PX = 2 +// Stripe thickness for the diagonal pattern. +export const STRIPE_SIZE_PX = 8 + +// Computes mini activity lane assignments (height index) and total lane count. +// If stacking is disabled, all mini activities are assigned to lane 0. +// Uses a greedy algorithm to minimize lane count while avoiding overlaps if stacking is enabled. +export const computeMiniLanes = ( + activities: T[], + stackMiniActivities: boolean, + viewMode: GanttChartViewMode = 'day' +): { lanes: MiniLaneItem[]; laneCount: number } => { + const minis = activities.filter((activity) => (activity.visualType ?? 'bar') === 'mini') + + if (minis.length === 0) { + return { lanes: [], laneCount: 1 } + } + + if (!stackMiniActivities) { + return { lanes: minis.map((activity) => ({ activity, laneIndex: 0 })), laneCount: 1 } + } + + const normalizeStart = (activity: T) => + DateTime.fromJSDate(activity.startDate) + .startOf(viewMode === 'week' ? 'week' : 'day') + .toMillis() + const normalizeEnd = (activity: T) => + DateTime.fromJSDate(activity.endDate) + .startOf(viewMode === 'week' ? 'week' : 'day') + .toMillis() + + const sorted = [...minis].sort((a, b) => normalizeStart(a) - normalizeStart(b)) + const laneEnds: number[] = [] + const lanes: MiniLaneItem[] = [] + + // Greedy lane assignment keeps mini bars vertically compact while avoiding overlaps. + sorted.forEach((activity) => { + const startMs = normalizeStart(activity) + const endMs = normalizeEnd(activity) + let laneIndex = laneEnds.findIndex((laneEnd) => startMs > laneEnd) + + if (laneIndex === -1) { + laneIndex = laneEnds.length + laneEnds.push(endMs) + } else { + laneEnds.splice(laneIndex, 1, endMs) + } + + lanes.push({ activity, laneIndex }) + }) + + return { lanes, laneCount: laneEnds.length } +} + +// Computes the required row height based on mini activity max stacking. +export const getRowHeight = ( + activities: T[], + stackMiniActivities: boolean, + viewMode: GanttChartViewMode = 'day' +) => { + const { laneCount } = computeMiniLanes(activities, stackMiniActivities, viewMode) + const extraHeight = Math.max(0, laneCount - 1) * (MINI_HEIGHT_PX + MINI_GAP_PX) + return BASE_ROW_HEIGHT_PX + extraHeight +} + +// Generates week columns covering the specified date range. +export const getWeekColumns = (startDate: Date, endDate: Date): WeekColumn[] => { + const start = DateTime.fromJSDate(startDate).startOf('week') + const end = DateTime.fromJSDate(endDate).startOf('week') + const weeks: WeekColumn[] = [] + let cursor = start + + while (cursor <= end) { + const weekStart = cursor + const weekEnd = cursor.endOf('week') + weeks.push({ + start: weekStart.toJSDate(), + end: weekEnd.toJSDate(), + weekYear: weekStart.weekYear, + weekNumber: weekStart.weekNumber, + }) + cursor = cursor.plus({ weeks: 1 }) + } + + return weeks +} + +// Computes the pixel left offset and width for an activity in the Gantt chart. +export const getActivitySpanPx = ( + activity: GanttChartActivityLike, + dateRange: Date[], + viewMode: GanttChartViewMode, + columnWidthPx: number, + weekColumns: WeekColumn[] +) => { + if (dateRange.length === 0) { + return { left: 0, width: 0 } + } + + if (viewMode === 'week') { + const maxIndex = Math.max(0, weekColumns.length - 1) + const startWeek = DateTime.fromJSDate(activity.startDate).startOf('week').toISO() + const endWeek = DateTime.fromJSDate(activity.endDate).startOf('week').toISO() + const indexByWeek = new Map( + weekColumns.map((week, index) => [DateTime.fromJSDate(week.start).toISO(), index]) + ) + const rawStart = indexByWeek.get(startWeek) ?? 0 + const rawEnd = indexByWeek.get(endWeek) ?? maxIndex + const startIndex = Math.min(maxIndex, Math.max(0, rawStart)) + const endIndex = Math.min(maxIndex, Math.max(startIndex, rawEnd)) + + return { + left: startIndex * columnWidthPx, + width: (endIndex - startIndex + 1) * columnWidthPx, + } + } + + const rangeStart = DateTime.fromJSDate(dateRange[0]!).startOf('day') + const activityStart = DateTime.fromJSDate(activity.startDate).startOf('day') + const activityEnd = DateTime.fromJSDate(activity.endDate).startOf('day') + const offsetDays = Math.max(0, Math.floor(activityStart.diff(rangeStart, 'days').days)) + const spanDays = Math.max(1, Math.floor(activityEnd.diff(activityStart, 'days').days) + 1) + + return { + left: offsetDays * columnWidthPx, + width: spanDays * columnWidthPx, + } +} + +// Computes month spans (start and end week indices) for the given week columns. +export const getMonthSpansForWeeks = (weeks: WeekColumn[]): MonthSpan[] => { + if (weeks.length === 0) { + return [] + } + + const firstWeekStart = DateTime.fromJSDate(weeks[0]!.start).startOf('month') + const lastWeekEnd = DateTime.fromJSDate(weeks[weeks.length - 1]!.end).startOf('month') + const spans: MonthSpan[] = [] + let cursor = firstWeekStart + + // Month headers can overlap week boundaries, so compute spans by overlap, not by week start. + while (cursor <= lastWeekEnd) { + const monthStart = cursor.startOf('month') + const monthEnd = cursor.endOf('month') + const indices = weeks + .map((week, index) => ({ + index, + overlaps: + DateTime.fromJSDate(week.start) <= monthEnd && + DateTime.fromJSDate(week.end) >= monthStart, + })) + .filter((item) => item.overlaps) + .map((item) => item.index) + + if (indices.length > 0) { + spans.push({ + year: cursor.year, + month: cursor.month, + label: cursor.toFormat('MMM yyyy'), + startIndex: indices[0]!, + endIndex: indices[indices.length - 1]!, + }) + } + + cursor = cursor.plus({ months: 1 }) + } + + return spans +} diff --git a/vue/src/components/GanttChartComponent/ganttChartTypes.ts b/vue/src/components/GanttChartComponent/ganttChartTypes.ts new file mode 100644 index 00000000..88ba78ee --- /dev/null +++ b/vue/src/components/GanttChartComponent/ganttChartTypes.ts @@ -0,0 +1,24 @@ +export type GanttChartActivityData = { + id?: string | number + label?: string + startDate: Date + endDate: Date + visualType?: 'stripe' | 'bar' | 'mini' + color?: string + colorClass?: string +} + +export type GanttChartRowData = { + id?: string | number + label?: string + header?: string + activities: GanttChartActivityData[] +} + +export type GanttChartLinkData = { + id?: string | number + fromId: string | number + toId: string | number + type?: 'finish-start' | 'start-start' + color?: string +} diff --git a/vue/src/components/GanttChartComponent/index.ts b/vue/src/components/GanttChartComponent/index.ts new file mode 100644 index 00000000..906804f8 --- /dev/null +++ b/vue/src/components/GanttChartComponent/index.ts @@ -0,0 +1 @@ +export { default as GanttChartComponent } from './GanttChartComponent.vue' diff --git a/vue/src/demo/router.ts b/vue/src/demo/router.ts index 29463571..e068d4d9 100644 --- a/vue/src/demo/router.ts +++ b/vue/src/demo/router.ts @@ -1,6 +1,7 @@ import { createRouter, createWebHistory } from 'vue-router' import DemoView from './DemoView.vue' import ShowcaseView from './ShowcaseView.vue' +import PlaygroundView from '@/demo/views/PlaygroundView.vue' const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), @@ -15,6 +16,11 @@ const router = createRouter({ name: 'showcase', component: ShowcaseView, }, + { + path: '/playground', + name: 'playground', + component: PlaygroundView, + }, { path: '/:pathMatch(.*)*', name: '404', diff --git a/vue/src/demo/views/PlaygroundView.vue b/vue/src/demo/views/PlaygroundView.vue new file mode 100644 index 00000000..5ef25a76 --- /dev/null +++ b/vue/src/demo/views/PlaygroundView.vue @@ -0,0 +1,86 @@ + + + diff --git a/vue/src/lib.ts b/vue/src/lib.ts index 77ad8fd2..9ae9f1d8 100644 --- a/vue/src/lib.ts +++ b/vue/src/lib.ts @@ -9,6 +9,7 @@ export { PrimeVue as PrimeVueLibConfig } // Export your library components, functions and props in this file export * from '@/components/AutoroutedBreadcrumb' export * from '@/components/ControlBarComponent' +export * from '@/components/GanttChartComponent' export * from '@/components/DashboardComponent' export * from '@/components/FormComponent' export * from '@/components/NetworkButton' diff --git a/vue/src/locales/en/gantt_chart.json b/vue/src/locales/en/gantt_chart.json new file mode 100644 index 00000000..63fe280a --- /dev/null +++ b/vue/src/locales/en/gantt_chart.json @@ -0,0 +1,6 @@ +{ + "week": "Week", + "header": "Header", + "row": "Row", + "activity": "Activity" +} diff --git a/vue/src/locales/index.ts b/vue/src/locales/index.ts index 25b3629c..da951524 100644 --- a/vue/src/locales/index.ts +++ b/vue/src/locales/index.ts @@ -31,7 +31,6 @@ export function loadTranslations( if (matched && matched.length > 2) { const locale = matched[1] const rootField = matched[2] - /* eslint-disable security/detect-object-injection */ const moduleData = modules[path] // Ensure we have valid locale, rootField, and moduleData before proceeding @@ -42,7 +41,6 @@ export function loadTranslations( } messages[locale][rootField] = moduleData.default } - /* eslint-enable security/detect-object-injection */ } } return messages @@ -61,7 +59,6 @@ export function createLibI18n(options: I18nOptions = {}) { const mergedMessages: LocaleMessages = {} // Safe local assignation. No safeguard needed. - /* eslint-disable security/detect-object-injection */ for (const locale in defaultMessages) { mergedMessages[locale] = { ...defaultMessages[locale] } if (options.messages?.[locale]) { @@ -78,7 +75,6 @@ export function createLibI18n(options: I18nOptions = {}) { } } } - /* eslint-enable security/detect-object-injection */ return createI18n({ legacy: false, // Use Composition API diff --git a/vue/src/utils/translations.ts b/vue/src/utils/translations.ts index a919ee1f..5775f104 100644 --- a/vue/src/utils/translations.ts +++ b/vue/src/utils/translations.ts @@ -32,14 +32,12 @@ export function applyTranslations( if (i === keys.length - 1) { return { parent, lastKey: key } } - /* eslint-disable security/detect-object-injection */ if (!target[key] || typeof target[key] !== 'object') { return { parent: null, lastKey: null } } target[key] = { ...target[key] } as Record parent = target target = target[key] as Record - /* eslint-enable security/detect-object-injection */ i++ } return { parent: null, lastKey: null } @@ -49,13 +47,11 @@ export function applyTranslations( const keys = path.split('.') const { parent, lastKey } = traversePath(result, keys) - /* eslint-disable security/detect-object-injection */ if (parent && lastKey && typeof parent[lastKey] === 'string') { const translationKey = parent[lastKey] const translated = t(translationKey) parent[lastKey] = translated !== translationKey ? translated : translationKey } - /* eslint-enable security/detect-object-injection */ } return result as T