add time overview page

This commit is contained in:
Gregor Vostrak
2024-03-26 18:19:08 +01:00
parent ab9a1d2fab
commit 26fef8b9f7
52 changed files with 2463 additions and 972 deletions

438
e2e/time.spec.ts Normal file
View File

@@ -0,0 +1,438 @@
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures';
import { expect, Page } from '@playwright/test';
import {
assertThatTimerHasStarted,
assertThatTimerIsStopped,
newTimeEntryResponse,
startOrStopTimerWithButton,
stoppedTimeEntryResponse,
} from './utils/currentTimeEntry';
async function goToTimeOverview(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
}
async function createEmptyTimeEntry(page: Page) {
await Promise.all([
newTimeEntryResponse(page),
startOrStopTimerWithButton(page),
assertThatTimerHasStarted(page),
]);
await page.waitForTimeout(1500);
await Promise.all([
stoppedTimeEntryResponse(page),
startOrStopTimerWithButton(page),
]);
await assertThatTimerIsStopped(page);
}
test('test that starting and stopping an empty time entry shows a new time entry in the overview', async ({
page,
}) => {
await Promise.all([
goToTimeOverview(page),
page.waitForResponse(
(response) =>
response.url().includes('/time-entries') &&
response.status() === 200
),
]);
// check that there are not testid time_entry_row elements on the page
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
const initialTimeEntryCount = await timeEntryRows.count();
await createEmptyTimeEntry(page);
await expect(timeEntryRows).toHaveCount(initialTimeEntryCount + 1);
});
// Test that description update works
test('test that updating a description of a time entry in the overview works on blur', async ({
page,
}) => {
await goToTimeOverview(page);
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
await createEmptyTimeEntry(page);
await page.waitForResponse(
(response) =>
response.url().includes('/time-entries') &&
response.status() === 200
);
const newTimeEntry = timeEntryRows.first();
await newTimeEntry.locator('[data-testid="timer_button"].bg-accent-300/70');
const newDescription = Math.floor(Math.random() * 1000000).toString();
const descriptionElement = newTimeEntry.getByTestId(
'time_entry_description'
);
await descriptionElement.fill(newDescription);
await Promise.all([
descriptionElement.press('Tab'),
page.waitForResponse(async (response) => {
return (
response.status() === 200 &&
(await response.headerValue('Content-Type')) ===
'application/json' &&
(await response.json()).data.id !== null &&
(await response.json()).data.start !== null &&
(await response.json()).data.end !== null &&
(await response.json()).data.project_id === null &&
(await response.json()).data.description == newDescription &&
(await response.json()).data.task_id === null &&
(await response.json()).data.duration !== null &&
(await response.json()).data.user_id !== null &&
JSON.stringify((await response.json()).data.tags) ===
JSON.stringify([])
);
}),
]);
});
test('test that updating a description of a time entry in the overview works on enter', async ({
page,
}) => {
await goToTimeOverview(page);
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
await createEmptyTimeEntry(page);
await page.waitForResponse(
(response) =>
response.url().includes('/time-entries') &&
response.status() === 200
);
const newTimeEntry = timeEntryRows.first();
await newTimeEntry.locator('[data-testid="timer_button"].bg-accent-300/70');
const newDescription = Math.floor(Math.random() * 1000000).toString();
const descriptionElement = newTimeEntry.getByTestId(
'time_entry_description'
);
await descriptionElement.fill(newDescription);
await Promise.all([
descriptionElement.press('Enter'),
page.waitForResponse(async (response) => {
return (
response.status() === 200 &&
(await response.headerValue('Content-Type')) ===
'application/json' &&
(await response.json()).data.id !== null &&
(await response.json()).data.start !== null &&
(await response.json()).data.end !== null &&
(await response.json()).data.project_id === null &&
(await response.json()).data.description == newDescription &&
(await response.json()).data.task_id === null &&
(await response.json()).data.duration !== null &&
(await response.json()).data.user_id !== null &&
JSON.stringify((await response.json()).data.tags) ===
JSON.stringify([])
);
}),
]);
});
test('test that adding a new tag to an existing time entry works', async ({
page,
}) => {
await goToTimeOverview(page);
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
await createEmptyTimeEntry(page);
await page.waitForResponse(
(response) =>
response.url().includes('/time-entries') &&
response.status() === 200
);
const newTimeEntry = timeEntryRows.first();
await newTimeEntry.locator('[data-testid="timer_button"].bg-accent-300/70');
const newTagName = Math.floor(Math.random() * 1000000).toString();
await newTimeEntry.getByTestId('time_entry_tag_dropdown').click();
await newTimeEntry.getByTestId('tag_dropdown_search').fill(newTagName);
await Promise.all([
page.waitForResponse(async (response) => {
return (
response.status() === 201 &&
(await response.headerValue('Content-Type')) ===
'application/json' &&
(await response.json()).data.name === newTagName
);
}),
newTimeEntry.getByTestId('tag_dropdown_search').press('Enter'),
]);
await expect(newTimeEntry.getByTestId('tag_dropdown_search')).toHaveValue(
''
);
await expect(
newTimeEntry.getByRole('option', { name: newTagName })
).toBeVisible();
});
// Test that Start / End Time Update Works
test('test that updating a the start of an existing time entry in the overview works on blur', async ({
page,
}) => {
await goToTimeOverview(page);
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
await createEmptyTimeEntry(page);
await page.waitForResponse(
(response) =>
response.url().includes('/time-entries') &&
response.status() === 200
);
const newTimeEntry = timeEntryRows.first();
await newTimeEntry.locator('[data-testid="timer_button"].bg-accent-300/70');
await page.waitForTimeout(1500);
const timeEntryRangeElement = newTimeEntry.getByTestId(
'time_entry_range_selector'
);
await timeEntryRangeElement.click();
await newTimeEntry
.getByTestId('time_entry_range_start')
.getByTestId('time_picker_hour')
.fill('1');
await newTimeEntry
.getByTestId('time_entry_range_start')
.getByTestId('time_picker_minute')
.fill('1');
await Promise.all([
page.waitForResponse(async (response) => {
return (
response.status() === 200 &&
(await response.headerValue('Content-Type')) ===
'application/json' &&
(await response.json()).data.id !== null &&
// TODO! Actually check the value
(await response.json()).data.start !== null &&
(await response.json()).data.end !== null
);
}),
newTimeEntry
.getByTestId('time_entry_range_end')
.getByTestId('time_picker_minute')
.press('Tab'),
]);
});
test('test that updating a the duration in the overview works on blur', async ({
page,
}) => {
await goToTimeOverview(page);
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
await createEmptyTimeEntry(page);
await page.waitForResponse(
(response) =>
response.url().includes('/time-entries') &&
response.status() === 200
);
const newTimeEntry = timeEntryRows.first();
await newTimeEntry.locator('[data-testid="timer_button"].bg-accent-300/70');
await page.waitForTimeout(1500);
const timeEntryDurationInput = newTimeEntry.getByTestId(
'time_entry_duration_input'
);
await timeEntryDurationInput.fill('20min');
await Promise.all([
page.waitForResponse(async (response) => {
return (
response.status() === 200 &&
(await response.headerValue('Content-Type')) ===
'application/json' &&
(await response.json()).data.id !== null &&
// TODO! Actually check the value
(await response.json()).data.start !== null &&
(await response.json()).data.end !== null
);
}),
timeEntryDurationInput.press('Tab'),
]);
await expect(
newTimeEntry.getByTestId('time_entry_duration_input')
).toHaveValue('00h 20min');
});
// Test that start stop button stops running timer
test('test that stopping a time entry from the overview works', async ({
page,
}) => {
await goToTimeOverview(page);
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
await Promise.all([
newTimeEntryResponse(page),
startOrStopTimerWithButton(page),
assertThatTimerHasStarted(page),
page.waitForResponse(
(response) =>
response.url().includes('/time-entries') &&
response.status() === 200
),
]);
await page.waitForTimeout(1500);
const newTimeEntry = timeEntryRows.first();
const stopButton = newTimeEntry.getByTestId('timer_button');
await newTimeEntry.locator('[data-testid="timer_button"].bg-red-400/80');
await Promise.all([
page.waitForResponse(async (response) => {
return (
response.status() === 200 &&
(await response.headerValue('Content-Type')) ===
'application/json' &&
(await response.json()).data.id !== null &&
(await response.json()).data.start !== null &&
(await response.json()).data.end !== null
);
}),
stopButton.click(),
]);
await expect(newTimeEntry.getByTestId('timer_button')).toHaveClass(
/bg-accent-300\/50/
);
});
// Test that start stop button stops running timer
test('test that starting a time entry from the overview works', async ({
page,
}) => {
await goToTimeOverview(page);
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
await createEmptyTimeEntry(page);
await page.waitForResponse(
(response) =>
response.url().includes('/time-entries') &&
response.status() === 200
);
const newTimeEntry = timeEntryRows.first();
const startButton = newTimeEntry.getByTestId('timer_button');
await expect(startButton).toHaveClass(/bg-accent-300\/50/);
await Promise.all([
page.waitForResponse(async (response) => {
return (
response.status() === 200 &&
(await response.headerValue('Content-Type')) ===
'application/json' &&
(await response.json()).data.id !== null &&
(await response.json()).data.start !== null &&
(await response.json()).data.end !== null
);
}),
startButton.click(),
]);
await expect(startButton).toHaveClass(/bg-red-500\/80/);
await page.waitForTimeout(1500);
await Promise.all([
page.waitForResponse(async (response) => {
return (
response.status() === 200 &&
(await response.headerValue('Content-Type')) ===
'application/json' &&
(await response.json()).data.id !== null &&
(await response.json()).data.start !== null &&
(await response.json()).data.end !== null
);
}),
startOrStopTimerWithButton(page),
expect(startButton).toHaveClass(/bg-accent-300\/50/),
]);
});
test('test that updating a the duration in the overview for a running timer works on blur', async ({
page,
}) => {
await goToTimeOverview(page);
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
await Promise.all([
newTimeEntryResponse(page),
startOrStopTimerWithButton(page),
assertThatTimerHasStarted(page),
page.waitForResponse(
(response) =>
response.url().includes('/time-entries') &&
response.status() === 200
),
]);
await page.waitForTimeout(1500);
const newTimeEntry = timeEntryRows.first();
const startButton = newTimeEntry.getByTestId('timer_button');
await page.waitForTimeout(1500);
const timeEntryDurationInput = newTimeEntry.getByTestId(
'time_entry_duration_input'
);
await timeEntryDurationInput.fill('20min');
await Promise.all([
page.waitForResponse(async (response) => {
return (
response.status() === 200 &&
(await response.headerValue('Content-Type')) ===
'application/json' &&
(await response.json()).data.id !== null &&
// TODO! Actually check the value
(await response.json()).data.start !== null &&
(await response.json()).data.end !== null
);
}),
timeEntryDurationInput.press('Tab'),
]);
await expect(page.getByTestId('time_entry_time')).toHaveValue('00:20:00');
await Promise.all([
page.waitForResponse(async (response) => {
return (
response.status() === 200 &&
(await response.headerValue('Content-Type')) ===
'application/json' &&
(await response.json()).data.id !== null &&
(await response.json()).data.start !== null &&
(await response.json()).data.end !== null
);
}),
startOrStopTimerWithButton(page),
expect(startButton).toHaveClass(/bg-accent-300\/50/),
]);
});
test('test that deleting a time entry from the overview works', async ({
page,
}) => {
await goToTimeOverview(page);
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
await createEmptyTimeEntry(page);
await page.waitForResponse(
(response) =>
response.url().includes('/time-entries') &&
response.status() === 200
);
const timeEntryCount = await timeEntryRows.count();
const newTimeEntry = timeEntryRows.first();
const actionsDropdown = newTimeEntry.getByTestId('time_entry_actions');
await actionsDropdown.click();
const deleteButton = newTimeEntry.getByTestId('time_entry_delete');
await deleteButton.click();
await expect(timeEntryRows).toHaveCount(timeEntryCount - 1);
});
// TODO: Test that updating the time entry start / end times works while it is running
// TODO: Test for project update
// TODO: Test for resume button click works with project / task
// TODO: Test that time entries are loaded at the end of the page

View File

@@ -1,75 +1,29 @@
import { expect, test } from '../playwright/fixtures';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import {
assertThatTimerHasStarted,
assertThatTimerIsStopped,
newTimeEntryResponse,
startOrStopTimerWithButton,
stoppedTimeEntryResponse,
} from './utils/currentTimeEntry';
async function goToDashboard(page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
}
async function startOrStopTimerWithButton(page) {
await page
.locator('[data-testid="dashboard_timer"] [data-testid="timer_button"]')
.click();
}
async function assertThatTimerHasStarted(page) {
await page.locator(
'[data-testid="dashboard_timer"] [data-testid="timer_button"].bg-red-400/80'
);
}
function newTimeEntryResponse(page) {
return page.waitForResponse(async (response) => {
return (
response.status() === 201 &&
(await response.headerValue('Content-Type')) ===
'application/json' &&
(await response.json()).data.id !== null &&
(await response.json()).data.start !== null &&
(await response.json()).data.end === null &&
(await response.json()).data.project_id === null &&
(await response.json()).data.description === '' &&
(await response.json()).data.task_id === null &&
(await response.json()).data.duration === null &&
(await response.json()).data.user_id !== null &&
JSON.stringify((await response.json()).data.tags) ===
JSON.stringify([])
);
});
}
async function assertThatTimerIsStopped(page) {
await page.locator(
'[data-testid="dashboard_timer"] [data-testid="timer_button"].bg-accent-300/70'
);
}
test('test that starting and stopping a timer without description and project works', async ({
page,
}) => {
await goToDashboard(page);
const newTimeEntryPromise = newTimeEntryResponse(page);
await startOrStopTimerWithButton(page);
await newTimeEntryPromise;
await assertThatTimerHasStarted(page);
await Promise.all([
newTimeEntryResponse(page),
startOrStopTimerWithButton(page),
assertThatTimerHasStarted(page),
]);
await page.waitForTimeout(1500);
await Promise.all([
page.waitForResponse(async (response) => {
return (
response.status() === 200 &&
(await response.headerValue('Content-Type')) ===
'application/json' &&
(await response.json()).data.id !== null &&
(await response.json()).data.start !== null &&
(await response.json()).data.end !== null &&
(await response.json()).data.project_id === null &&
(await response.json()).data.description === '' &&
(await response.json()).data.task_id === null &&
(await response.json()).data.duration !== null &&
(await response.json()).data.user_id !== null &&
JSON.stringify((await response.json()).data.tags) ===
JSON.stringify([])
);
}),
stoppedTimeEntryResponse(page),
startOrStopTimerWithButton(page),
]);
await assertThatTimerIsStopped(page);
@@ -471,6 +425,12 @@ test('test that adding a new tag when the timer is running', async ({
await assertThatTimerIsStopped(page);
});
// test that adding a new tag when the timer is running
// test that search is working
// test that adding a tag and project and starting the timer afterwards works and sets the project and tag correctly
// test that changing the project works
// test that sidebar timetracker starts and stops timer
// test that sidebar timetracker changes state when tmer on dashboard is started

View File

@@ -0,0 +1,59 @@
import { Page } from '@playwright/test';
export async function startOrStopTimerWithButton(page: Page) {
await page
.locator('[data-testid="dashboard_timer"] [data-testid="timer_button"]')
.click();
}
export async function assertThatTimerHasStarted(page: Page) {
await page.locator(
'[data-testid="dashboard_timer"] [data-testid="timer_button"].bg-red-400/80'
);
}
export function newTimeEntryResponse(page: Page) {
return page.waitForResponse(async (response) => {
return (
response.status() === 201 &&
(await response.headerValue('Content-Type')) ===
'application/json' &&
(await response.json()).data.id !== null &&
(await response.json()).data.start !== null &&
(await response.json()).data.end === null &&
(await response.json()).data.project_id === null &&
(await response.json()).data.description === '' &&
(await response.json()).data.task_id === null &&
(await response.json()).data.duration === null &&
(await response.json()).data.user_id !== null &&
JSON.stringify((await response.json()).data.tags) ===
JSON.stringify([])
);
});
}
export async function assertThatTimerIsStopped(page: Page) {
await page.locator(
'[data-testid="dashboard_timer"] [data-testid="timer_button"].bg-accent-300/70'
);
}
export async function stoppedTimeEntryResponse(page: Page) {
return page.waitForResponse(async (response) => {
return (
response.status() === 200 &&
(await response.headerValue('Content-Type')) ===
'application/json' &&
(await response.json()).data.id !== null &&
(await response.json()).data.start !== null &&
(await response.json()).data.end !== null &&
(await response.json()).data.project_id === null &&
(await response.json()).data.description === '' &&
(await response.json()).data.task_id === null &&
(await response.json()).data.duration !== null &&
(await response.json()).data.user_id !== null &&
JSON.stringify((await response.json()).data.tags) ===
JSON.stringify([])
);
});
}

View File

@@ -13,6 +13,16 @@ const ClientCollection = z.array(ClientResource);
const v1_import_import_Body = z
.object({ type: z.string(), data: z.string() })
.passthrough();
const MemberResource = z
.object({
id: z.string(),
name: z.string(),
email: z.string(),
role: z.string(),
is_placeholder: z.boolean(),
})
.passthrough();
const MemberCollection = z.array(MemberResource);
const OrganizationResource = z
.object({ id: z.string(), name: z.string(), is_personal: z.string() })
.passthrough();
@@ -53,43 +63,40 @@ const TimeEntryResource = z
project_id: z.union([z.string(), z.null()]),
user_id: z.string(),
tags: z.array(z.string()),
billable: z.boolean(),
})
.passthrough();
const TimeEntryCollection = z.array(TimeEntryResource);
const createTimeEntry_Body = z
.object({
user_id: z.string().uuid(),
project_id: z.union([z.string(), z.null()]).optional(),
task_id: z.union([z.string(), z.null()]).optional(),
start: z.string(),
end: z.union([z.string(), z.null()]).optional(),
billable: z.boolean(),
description: z.union([z.string(), z.null()]).optional(),
tags: z.union([z.array(z.string()), z.null()]).optional(),
})
.passthrough();
const updateTimeEntry_Body = z
.object({
project_id: z.union([z.string(), z.null()]).optional(),
task_id: z.union([z.string(), z.null()]).optional(),
start: z.string(),
end: z.union([z.string(), z.null()]).optional(),
billable: z.boolean().optional(),
description: z.union([z.string(), z.null()]).optional(),
tags: z.union([z.array(z.string()), z.null()]).optional(),
})
.passthrough();
const UserResource = z
.object({
id: z.string(),
name: z.string(),
email: z.string(),
role: z.string(),
is_placeholder: z.boolean(),
})
.passthrough();
const UserCollection = z.array(UserResource);
export const schemas = {
ClientResource,
ClientCollection,
v1_import_import_Body,
MemberResource,
MemberCollection,
OrganizationResource,
ProjectResource,
ProjectCollection,
@@ -101,8 +108,6 @@ export const schemas = {
TimeEntryCollection,
createTimeEntry_Body,
updateTimeEntry_Body,
UserResource,
UserCollection,
};
const endpoints = makeApi([
@@ -392,6 +397,89 @@ const endpoints = makeApi([
},
],
},
{
method: 'get',
path: '/v1/organizations/:organization/members',
alias: 'v1.users.index',
requestFormat: 'json',
parameters: [
{
name: 'organization',
type: 'Path',
schema: z.string().uuid(),
},
],
response: z.object({ data: MemberCollection }).passthrough(),
errors: [
{
status: 403,
description: `Authorization error`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 404,
description: `Not found`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 422,
description: `Validation error`,
schema: z
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
},
{
method: 'post',
path: '/v1/organizations/:organization/members/:user/invite-placeholder',
alias: 'v1.users.invite-placeholder',
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: z.object({}).partial().passthrough(),
},
{
name: 'organization',
type: 'Path',
schema: z.string().uuid(),
},
{
name: 'user',
type: 'Path',
schema: z.string().uuid(),
},
],
response: z.null(),
errors: [
{
status: 400,
description: `API exception`,
schema: z
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(),
},
{
status: 403,
description: `Authorization error`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 404,
description: `Not found`,
schema: z.object({ message: z.string() }).passthrough(),
},
],
},
{
method: 'get',
path: '/v1/organizations/:organization/projects',
@@ -806,6 +894,17 @@ const endpoints = makeApi([
],
response: z.object({ data: TimeEntryResource }).passthrough(),
errors: [
{
status: 400,
description: `API exception`,
schema: z
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(),
},
{
status: 403,
description: `Authorization error`,
@@ -910,78 +1009,6 @@ const endpoints = makeApi([
},
],
},
{
method: 'get',
path: '/v1/organizations/:organization/users',
alias: 'v1.users.index',
requestFormat: 'json',
parameters: [
{
name: 'organization',
type: 'Path',
schema: z.string().uuid(),
},
],
response: z.object({ data: UserCollection }).passthrough(),
errors: [
{
status: 403,
description: `Authorization error`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 404,
description: `Not found`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 422,
description: `Validation error`,
schema: z
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
},
{
method: 'post',
path: '/v1/organizations/:organization/users/:user/invite-placeholder',
alias: 'v1.users.invite-placeholder',
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: z.object({}).partial().passthrough(),
},
{
name: 'organization',
type: 'Path',
schema: z.string().uuid(),
},
{
name: 'user',
type: 'Path',
schema: z.string().uuid(),
},
],
response: z.string(),
errors: [
{
status: 403,
description: `Authorization error`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 404,
description: `Not found`,
schema: z.object({ message: z.string() }).passthrough(),
},
],
},
]);
export const api = new Zodios('/api', endpoints);

904
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -23,24 +23,27 @@
"laravel-vite-plugin": "^1.0.0",
"openapi-zod-client": "^1.16.2",
"postcss": "^8.4.14",
"postcss-nesting": "^12.1.0",
"tailwindcss": "^3.1.0",
"typescript": "^5.3.3",
"vite": "^5.0.0",
"vite-plugin-checker": "^0.6.2",
"vue": "^3.2.31",
"vue": "^3.4.0",
"vue-tsc": "^1.8.27",
"ziggy-js": "^1.8.1"
},
"dependencies": {
"@heroicons/vue": "^2.1.1",
"@rushstack/eslint-patch": "^1.7.0",
"@tailwindcss/container-queries": "^0.1.1",
"@vue/eslint-config-prettier": "^9.0.0",
"@vue/eslint-config-typescript": "^12.0.0",
"@vue/eslint-config-typescript": "^13.0.0",
"@vueuse/core": "^10.9.0",
"dayjs": "^1.11.10",
"echarts": "^5.5.0",
"parse-duration": "^1.1.0",
"pinia": "^2.1.7",
"radix-vue": "^1.4.9",
"radix-vue": "^1.5.2",
"tailwind-merge": "^2.2.1",
"vue-echarts": "^6.6.9"
}

View File

@@ -18,7 +18,7 @@ export default defineConfig({
/* Retry on CI only */
retries: process.env.CI ? 1 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : 1,
workers: 1,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: process.env.CI ? 'line' : 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */

View File

@@ -1,5 +1,7 @@
export default {
plugins: {
'postcss-import': {},
'tailwindcss/nesting': {},
tailwindcss: {},
autoprefixer: {},
},

View File

@@ -8,6 +8,11 @@
--theme-color-card-background: #13152B;
}
*{
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
[x-cloak] {
display: none;

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { twMerge } from 'tailwind-merge';
import { computed } from 'vue';
const props = withDefaults(
defineProps<{
@@ -8,23 +9,27 @@ const props = withDefaults(
tag: string;
class?: string;
color: string;
border: boolean;
}>(),
{
size: 'base',
tag: 'div',
color: 'var(--theme-color-icon-default)',
border: true,
}
);
const indicatorClasses = {
base: 'w-2.5 h-2.5',
large: 'w-3 h-3',
};
const badgeClasses = {
base: 'py-1 px-2 space-x-1.5 text-xs',
large: 'py-1.5 px-3 space-x-2 text-sm text-muted',
};
const borderClasses = computed(() => {
if (props.border) {
return 'border-input-border border';
}
return '';
});
</script>
<template>
@@ -34,17 +39,11 @@ const badgeClasses = {
twMerge(
props.class,
badgeClasses[size],
'border-input-border border rounded inline-flex items-center font-semibold text-white'
borderClasses,
'rounded inline-flex items-center font-semibold text-white'
)
">
<div
:style="{ backgroundColor: color }"
:class="
twMerge(indicatorClasses[size], 'inline-block rounded-full')
"></div>
<span>
{{ name }}
</span>
<slot></slot>
</component>
</template>

View File

@@ -0,0 +1,42 @@
<script setup lang="ts">
import { computed } from 'vue';
import { twMerge } from 'tailwind-merge';
const active = defineModel({ default: false });
function toggleBillable() {
active.value = !active.value;
}
const iconColorClasses = computed(() => {
if (active.value) {
return 'text-accent-200/80 focus:text-accent-200 hover:text-accent-200';
} else {
return 'text-icon-default focus:text-icon-active hover:text-icon-active';
}
});
</script>
<template>
<button
@click="toggleBillable"
:class="
twMerge(
iconColorClasses,
'flex-shrink-0 ring-0 focus:outline-none focus:ring-0 transition focus:bg-card-background-seperator hover:bg-card-background-seperator rounded-full w-11 h-11 flex items-center justify-center'
)
">
<svg
class="h-7"
viewBox="0 0 8 14"
fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
d="M4 1V13M1 10.182L1.879 10.841C3.05 11.72 4.949 11.72 6.121 10.841C7.293 9.962 7.293 8.538 6.121 7.659C5.536 7.219 4.768 7 4 7C3.275 7 2.55 6.78 1.997 6.341C0.891 5.462 0.891 4.038 1.997 3.159C3.103 2.28 4.897 2.28 6.003 3.159L6.418 3.489"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round" />
</svg>
</button>
</template>
<style scoped></style>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { Component } from 'vue';
defineProps<{
title: string;
icon?: Component;
}>();
</script>
<template>
<h3
class="text-white font-bold pb-4 text-base flex items-center space-x-2.5">
<component
v-if="icon"
:is="icon"
class="w-6 text-icon-default"></component>
<span>
{{ title }}
</span>
</h3>
</template>
<style scoped></style>

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import { formatDate, formatHumanReadableDate } from '@/utils/time';
defineProps<{
date: string;
}>();
</script>
<template>
<div class="flex items-center space-x-2">
<svg class="w-5" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<g fill="none">
<path
d="m12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035c-.01-.004-.019-.001-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427c-.002-.01-.009-.017-.017-.018m.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093c.012.004.023 0 .029-.008l.004-.014l-.034-.614c-.003-.012-.01-.02-.02-.022m-.715.002a.023.023 0 0 0-.027.006l-.006.014l-.034.614c0 .012.007.02.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01z" />
<path
fill="currentColor"
d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7zm-5-9a1 1 0 0 1 1 1v1h2a2 2 0 0 1 2 2v3H3V7a2 2 0 0 1 2-2h2V4a1 1 0 0 1 2 0v1h6V4a1 1 0 0 1 1-1" />
</g>
</svg>
<span class="font-semibold text-white">
{{ formatHumanReadableDate(date) }}
</span>
<span class="font-semibold">
{{ formatDate(date) }}
</span>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,41 @@
<script setup lang="ts">
import { twMerge } from 'tailwind-merge';
import Badge from '@/Components/Common/Badge.vue';
const props = withDefaults(
defineProps<{
name: string;
size: 'base' | 'large';
tag: string;
class?: string;
color: string;
border: boolean;
}>(),
{
size: 'base',
tag: 'div',
color: 'var(--theme-color-icon-default)',
border: true,
}
);
const indicatorClasses = {
base: 'w-2.5 h-2.5',
large: 'w-3 h-3',
};
</script>
<template>
<Badge :name :size :tag :class="props.class" :color :border>
<div
:style="{ backgroundColor: props.color }"
:class="
twMerge(indicatorClasses[size], 'inline-block rounded-full')
"></div>
<span>
{{ name }}
</span>
</Badge>
</template>
<style scoped></style>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import ProjectBadge from '@/Components/common/ProjectBadge.vue';
import ProjectBadge from '@/Components/Common/Project/ProjectBadge.vue';
import { computed, nextTick, ref, watch } from 'vue';
import { type Project, useProjectsStore } from '@/utils/useProjects';
import { useProjectsStore } from '@/utils/useProjects';
import Dropdown from '@/Components/Dropdown.vue';
import {
ComboboxAnchor,
@@ -12,19 +12,21 @@ import {
ComboboxViewport,
} from 'radix-vue';
import { PlusCircleIcon } from '@heroicons/vue/20/solid';
import ProjectDropdownItem from '@/Components/common/ProjectDropdownItem.vue';
import ProjectDropdownItem from '@/Components/Common/Project/ProjectDropdownItem.vue';
import { storeToRefs } from 'pinia';
import { api } from '../../../../openapi.json.client';
import { api } from '../../../../../openapi.json.client';
import { usePage } from '@inertiajs/vue3';
import { getRandomColor } from '@/utils/color';
import type { Project } from '@/utils/api';
const searchValue = ref('');
const searchInput = ref<HTMLElement | null>(null);
const model = defineModel<Project>({
default: undefined,
const model = defineModel<string | null>({
default: null,
});
const open = ref(false);
const projectsStore = useProjectsStore();
const emit = defineEmits(['update:modelValue', 'changed']);
const { projects } = storeToRefs(projectsStore);
const projectDropdownTrigger = ref<HTMLElement | null>(null);
@@ -36,6 +38,15 @@ const shownProjects = computed(() => {
});
});
withDefaults(
defineProps<{
border: boolean;
}>(),
{
border: true,
}
);
const page = usePage<{
auth: {
user: {
@@ -54,7 +65,7 @@ async function addProjectIfNoneExists() {
{ params: { organization: page.props.auth.user.current_team_id } }
);
projects.value.unshift(response.data);
model.value = response.data;
model.value = response.data.id;
searchValue.value = '';
open.value = false;
}
@@ -67,23 +78,32 @@ watch(open, (isOpen) => {
searchInput.value?.$el?.focus();
});
projects.value.sort((a) => {
return model.value === a ? -1 : 1;
projects.value.sort((iteratingProject) => {
return model.value === iteratingProject.id ? -1 : 1;
});
}
});
const currentProject = computed(() => {
return projects.value.find((project) => project.id === model.value);
});
function isProjectSelected(project: Project) {
return model.value?.id === project.id;
return model.value === project.id;
}
const selectedProjectName = computed(() => {
return model.value?.name || 'No Project';
return currentProject.value?.name || 'No Project';
});
const selectedProjectColor = computed(() => {
return model.value?.color || 'var(--theme-color-icon-default)';
return currentProject.value?.color || 'var(--theme-color-icon-default)';
});
function updateValue(project: Project) {
model.value = project.id;
emit('changed');
}
</script>
<template>
@@ -93,6 +113,7 @@ const selectedProjectColor = computed(() => {
ref="projectDropdownTrigger"
:color="selectedProjectColor"
size="large"
:border
tag="button"
:name="selectedProjectName"
class="focus:border-input-border-active focus:outline-0 focus:bg-card-background-seperator hover:bg-card-background-seperator"></ProjectBadge>
@@ -101,8 +122,10 @@ const selectedProjectColor = computed(() => {
<template #content>
<ComboboxRoot
:open="open"
v-model="model"
v-model:searchTerm="searchValue"
:modelValue="currentProject"
@update:modelValue="updateValue"
@update:searchTerm="(e) => console.log(e)"
:searchTerm="searchValue"
class="relative">
<ComboboxAnchor>
<ComboboxInput
@@ -119,7 +142,8 @@ const selectedProjectColor = computed(() => {
:data-project-id="null"
:value="{
id: null,
name: '',
name: 'No Project',
color: 'var(--theme-color-icon-default)',
}">
<ProjectDropdownItem
name="No Project"

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
defineProps<{
title: string;
value: string;
}>();
</script>
<template>
<div
class="rounded-lg bg-card-background border-card-border border px-3.5 py-2.5">
<dt class="font-bold text-sm text-muted">{{ title }}</dt>
<dd class="text-2xl text-white pt-1 font-bold">
{{ value }}
</dd>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
import { twMerge } from 'tailwind-merge';
import Badge from '@/Components/Common/Badge.vue';
import { TagIcon } from '@heroicons/vue/20/solid';
const props = withDefaults(
defineProps<{
name: string;
size: 'base' | 'large';
tag: string;
class?: string;
color: string;
border: boolean;
}>(),
{
size: 'base',
tag: 'div',
color: 'var(--theme-color-icon-default)',
border: true,
}
);
const indicatorClasses = {
base: 'w-3 h-3',
large: 'w-5 h-5',
};
</script>
<template>
<Badge :name :size :tag :class="props.class" :color :border>
<TagIcon
:style="{ color: color }"
:class="twMerge(indicatorClasses[size])"></TagIcon>
<span>
{{ name }}
</span>
</Badge>
</template>
<style scoped></style>

View File

@@ -0,0 +1,193 @@
<script setup lang="ts">
import { PlusCircleIcon } from '@heroicons/vue/20/solid';
import Dropdown from '@/Components/Dropdown.vue';
import { type Component, computed, nextTick, ref, watch } from 'vue';
import TagDropdownItem from '@/Components/Common/Tag/TagDropdownItem.vue';
import { useTagsStore } from '@/utils/useTags';
import { storeToRefs } from 'pinia';
const tagsStore = useTagsStore();
const { tags } = storeToRefs(tagsStore);
const model = defineModel<string[]>({
default: [],
});
const searchInput = ref<HTMLInputElement | null>(null);
const open = ref(false);
const dropdownViewport = ref<Component | null>(null);
const searchValue = ref('');
function isTagSelected(id: string) {
return model.value.includes(id);
}
function addOrRemoveTagFromSelection(id: string) {
if (model.value.includes(id)) {
model.value = model.value.filter((tagId) => tagId !== id);
} else {
model.value.push(id);
}
emit('changed');
}
watch(open, (isOpen) => {
if (isOpen) {
nextTick(() => {
searchInput.value?.focus();
});
// sort tags alphabetically
tags.value.sort((a, b) => {
const aIsSelected = model.value.includes(a.id);
const bIsSelected = model.value.includes(b.id);
if (aIsSelected === bIsSelected) {
return a.name.localeCompare(b.name);
}
return model.value.includes(a.id) ? -1 : 1;
});
}
});
const filteredTags = computed(() => {
return tags.value.filter((tag) => {
return tag.name
.toLowerCase()
.includes(searchValue.value?.toLowerCase()?.trim() || '');
});
});
async function addTagIfNoneExists() {
if (searchValue.value.length > 0 && filteredTags.value.length === 0) {
const newTag = await tagsStore.createTag(searchValue.value);
addOrRemoveTagFromSelection(newTag.id);
searchValue.value = '';
} else {
if (highlightedItemId.value) {
addOrRemoveTagFromSelection(highlightedItemId.value);
}
}
}
watch(filteredTags, () => {
if (filteredTags.value.length > 0) {
highlightedItemId.value = filteredTags.value[0].id;
}
});
function updateSearchValue(event: Event) {
const newInput = (event.target as HTMLInputElement).value;
if (newInput === ' ') {
searchValue.value = '';
const highlightedTagId = highlightedItemId.value;
if (highlightedTagId) {
const highlightedTag = tags.value.find(
(tag) => tag.id === highlightedTagId
);
if (highlightedTag) {
addOrRemoveTagFromSelection(highlightedTag.id);
}
}
} else {
searchValue.value = newInput;
}
}
const emit = defineEmits(['update:modelValue', 'changed']);
function toggleTag(newValue: string) {
if (model.value.includes(newValue)) {
model.value = model.value.filter((id) => id !== newValue);
} else {
model.value.push(newValue);
}
emit('changed');
}
function moveHighlightUp() {
if (highlightedItem.value) {
const currentHightlightedIndex = filteredTags.value.indexOf(
highlightedItem.value
);
if (currentHightlightedIndex === 0) {
highlightedItemId.value =
filteredTags.value[filteredTags.value.length - 1].id;
} else {
highlightedItemId.value =
filteredTags.value[currentHightlightedIndex - 1].id;
}
}
}
function moveHighlightDown() {
if (highlightedItem.value) {
const currentHightlightedIndex = filteredTags.value.indexOf(
highlightedItem.value
);
if (currentHightlightedIndex === filteredTags.value.length - 1) {
highlightedItemId.value = filteredTags.value[0].id;
} else {
highlightedItemId.value =
filteredTags.value[currentHightlightedIndex + 1].id;
}
}
console.log('move down');
}
const highlightedItemId = ref<string | null>(null);
const highlightedItem = computed(() => {
return tags.value.find((tag) => tag.id === highlightedItemId.value);
});
</script>
<template>
<Dropdown width="120" v-model="open" :closeOnContentClick="false">
<template #trigger>
<slot name="trigger"></slot>
</template>
<template #content>
<input
:value="searchValue"
@input="updateSearchValue"
@keydown.enter="addTagIfNoneExists"
data-testid="tag_dropdown_search"
@keydown.up.prevent="moveHighlightUp"
@keydown.down.prevent="moveHighlightDown"
ref="searchInput"
class="bg-card-background border-0 placeholder-muted text-sm text-white py-2.5 focus:ring-0 border-b border-card-background-seperator focus:border-card-background-seperator w-full"
placeholder="Search for a tag..." />
<div ref="dropdownViewport" class="w-60">
<div
v-if="searchValue.length > 0 && filteredTags.length === 0"
class="bg-card-background-active">
<div
class="flex space-x-3 items-center px-4 py-3 text-xs font-medium border-t rounded-b-lg border-card-background-seperator">
<PlusCircleIcon
class="w-5 flex-shrink-0"></PlusCircleIcon>
<span>Add "{{ searchValue }}" as a new Tag</span>
</div>
</div>
<div v-else></div>
<div
v-for="tag in filteredTags"
:key="tag.id"
role="option"
:value="tag.id"
:class="{
'bg-card-background-active':
tag.id === highlightedItemId,
}"
data-testid="tag_dropdown_entries"
:data-tag-id="tag.id">
<TagDropdownItem
:selected="isTagSelected(tag.id)"
@click="toggleTag(tag.id)"
:name="tag.name"></TagDropdownItem>
</div>
</div>
</template>
</Dropdown>
</template>
<style scoped></style>

View File

@@ -0,0 +1,63 @@
<script setup lang="ts">
const value = defineModel();
const emit = defineEmits(['changed']);
function onChange(event: Event) {
const target = event.target as HTMLInputElement;
emit('changed', target.value);
}
</script>
<template>
<div>
<label class="input-sizer text-sm font-medium" :data-value="value">
<input
data-testid="time_entry_description"
v-model="value"
@blur="onChange"
@keydown.enter="onChange"
placeholder="Add a description"
class="text-white placeholder-muted font-medium bg-transparent hover:bg-card-background rounded-lg border border-transparent hover:border-card-border" />
</label>
</div>
</template>
<style scoped lang="postcss">
.input-sizer {
display: inline-grid;
vertical-align: top;
align-items: center;
position: relative;
&.stacked {
align-items: stretch;
&::after,
input,
textarea {
grid-area: 2 / 1;
}
}
&::after,
input,
textarea {
width: auto;
min-width: 1em;
grid-area: 1 / 2;
padding: 0.5rem 0.75rem;
margin: 0;
font: inherit;
resize: none;
background: none;
appearance: none;
border: none;
}
&::after {
content: attr(data-value) ' ';
visibility: hidden;
white-space: pre-wrap;
}
}
</style>

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
import Dropdown from '@/Components/Dropdown.vue';
import { TrashIcon } from '@heroicons/vue/20/solid';
const emit = defineEmits<{
delete: [];
}>();
</script>
<template>
<Dropdown>
<template #trigger>
<svg
data-testid="time_entry_actions"
class="h-10 w-10 p-2 rounded-full hover:bg-card-background opacity-20 group-hover:opacity-100 transition"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M12 5.92A.96.96 0 1 0 12 4a.96.96 0 0 0 0 1.92m0 7.04a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92M12 20a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92" />
</svg>
</template>
<template #content>
<button
@click="emit('delete')"
data-testid="time_entry_delete"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
<TrashIcon class="w-5 text-icon-active"></TrashIcon>
<span>Delete</span>
</button>
</template>
</Dropdown>
</template>
<style scoped></style>

View File

@@ -0,0 +1,82 @@
<script setup lang="ts">
import Dropdown from '@/Components/Dropdown.vue';
import { defineProps, ref, watch } from 'vue';
import { formatTime } from '@/utils/time';
import TimePicker from '@/Components/Common/TimePicker.vue';
import { useFocusWithin } from '@vueuse/core';
const props = defineProps<{
start: string;
end: string | null;
}>();
function formatStartEnd(start: string, end: string | null) {
if (end) {
return `${formatTime(start)} - ${formatTime(end)}`;
} else {
return `${formatTime(start)} - ...`;
}
}
const emit = defineEmits(['changed']);
const tempStart = ref(props.start);
const tempEnd = ref(props.end || null);
watch(props, () => {
tempStart.value = props.start;
tempEnd.value = props.end;
});
function updateTimeEntry() {
emit('changed', tempStart.value, tempEnd.value);
}
const dropdownContent = ref();
const { focused } = useFocusWithin(dropdownContent);
watch(focused, (newValue, oldValue) => {
if (oldValue === true && newValue === false) {
console.log(newValue, oldValue);
updateTimeEntry();
}
});
</script>
<template>
<div class="relative">
<Dropdown
align="right"
:close-on-content-click="false"
@submit="updateTimeEntry">
<template #trigger>
<button
data-testid="time_entry_range_selector"
class="text-muted w-[110px] px-2 py-2 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-medium">
{{ formatStartEnd(start, end) }}
</button>
</template>
<template #content>
<div
ref="dropdownContent"
class="grid grid-cols-2 divide-x divide-card-background-seperator text-center py-1">
<div>
<div class="font-bold text-white text-sm pb-1">
Start
</div>
<TimePicker
data-testid="time_entry_range_start"
@updated="updateTimeEntry"
v-model="tempStart"></TimePicker>
</div>
<div>
<div class="font-bold text-white text-sm pb-1">End</div>
<TimePicker
data-testid="time_entry_range_end"
@updated="updateTimeEntry"
v-model="tempEnd"></TimePicker>
</div>
</div>
</template>
</Dropdown>
</div>
</template>
<style></style>

View File

@@ -0,0 +1,130 @@
<script setup lang="ts">
import MainContainer from '@/Pages/MainContainer.vue';
import TimeTrackerStartStop from '@/Components/Common/TimeTrackerStartStop.vue';
import TimeEntryRangeSelector from '@/Components/Common/TimeEntry/TimeEntryRangeSelector.vue';
import type { Project, TimeEntry } from '@/utils/api';
import { computed } from 'vue';
import { useProjectsStore } from '@/utils/useProjects';
import { storeToRefs } from 'pinia';
import ProjectDropdown from '@/Components/Common/Project/ProjectDropdown.vue';
import TimeEntryDescriptionInput from '@/Components/Common/TimeEntry/TimeEntryDescriptionInput.vue';
import { useTimeEntriesStore } from '@/utils/useTimeEntries';
import TimeEntryRowTagDropdown from '@/Components/Common/TimeEntry/TimeEntryRowTagDropdown.vue';
import TimeEntryRowDurationInput from '@/Components/Common/TimeEntry/TimeEntryRowDurationInput.vue';
import dayjs from 'dayjs';
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
import TimeEntryMoreOptionsDropdown from '@/Components/Common/TimeEntry/TimeEntryMoreOptionsDropdown.vue';
const projectsStore = useProjectsStore();
const { projects } = storeToRefs(projectsStore);
const currentTimeEntryStore = useCurrentTimeEntryStore();
const { stopTimer, updateTimer } = currentTimeEntryStore;
const { currentTimeEntry } = storeToRefs(currentTimeEntryStore);
const props = defineProps<{
timeEntry: TimeEntry;
}>();
const { updateTimeEntry, createTimeEntry, fetchTimeEntries } =
useTimeEntriesStore();
const timeEntryProject = computed<Project | undefined>(() => {
return projects.value.find(
(project) => project.id === props.timeEntry.project_id
);
});
async function updateStartEndTime(start: string, end: string | null) {
if (currentTimeEntry.value.id === props.timeEntry.id) {
currentTimeEntry.value.start = start;
currentTimeEntry.value.end = end;
await updateTimer();
} else {
await updateTimeEntry({ ...props.timeEntry, start, end });
}
await fetchTimeEntries();
}
async function onStartStopClick() {
if (props.timeEntry.start && !props.timeEntry.end) {
await updateTimeEntry({
...props.timeEntry,
end: dayjs().utc().format(),
});
} else {
if (currentTimeEntry.value.id) {
await stopTimer();
}
await createTimeEntry({
...props.timeEntry,
start: dayjs().utc().format(),
end: null,
});
}
useCurrentTimeEntryStore().fetchCurrentTimeEntry();
fetchTimeEntries();
}
function deleteTimeEntry() {
useTimeEntriesStore().deleteTimeEntry(props.timeEntry.id);
fetchTimeEntries();
}
function updateTimeEntryDescription(description: string) {
updateTimeEntry({ ...props.timeEntry, description });
}
</script>
<template>
<div
class="border-b border-card-border transition"
data-testid="time_entry_row">
<MainContainer>
<div class="flex py-1.5 items-center justify-between group">
<div class="flex space-x-1 items-center">
<input
type="checkbox"
class="h-4 w-4 rounded bg-card-background border-input-border text-accent-500/80 focus:ring-accent-500/80" />
<TimeEntryDescriptionInput
@changed="updateTimeEntryDescription"
:modelValue="
timeEntry.description
"></TimeEntryDescriptionInput>
<ProjectDropdown
:border="false"
:value="timeEntryProject"></ProjectDropdown>
</div>
<div class="flex items-center font-medium space-x-2">
<TimeEntryRowTagDropdown
@changed="updateTimeEntry(timeEntry)"
:modelValue="timeEntry.tags"></TimeEntryRowTagDropdown>
<div>
<TimeEntryRangeSelector
:start="timeEntry.start"
:end="timeEntry.end"
@changed="
updateStartEndTime
"></TimeEntryRangeSelector>
</div>
<TimeEntryRowDurationInput
:start="timeEntry.start"
:end="timeEntry.end"
@changed="
updateStartEndTime
"></TimeEntryRowDurationInput>
<TimeTrackerStartStop
@changed="onStartStopClick"
:active="!!(timeEntry.start && !timeEntry.end)"
class="opacity-20 group-hover:opacity-100"></TimeTrackerStartStop>
<TimeEntryMoreOptionsDropdown
@delete="
deleteTimeEntry
"></TimeEntryMoreOptionsDropdown>
</div>
</div>
</MainContainer>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,68 @@
<script setup lang="ts">
import { calculateDifference, formatHumanReadableDuration } from '@/utils/time';
import { computed, defineProps, ref } from 'vue';
import parse from 'parse-duration';
import dayjs from 'dayjs';
const props = defineProps<{
start: string;
end: string | null;
}>();
const emit = defineEmits<{
changed: [start: string, end: string | null];
}>();
const temporaryCustomTimerEntry = ref<string>('');
function updateTimerAndStartLiveTimerUpdate() {
const time = parse(temporaryCustomTimerEntry.value, 's');
if (time && time > 0) {
let newEndDate = props.end;
let newStartDate = props.start;
if (props.end) {
// only update end for time entries that are already finished
newEndDate = dayjs(props.start).utc().add(time, 's').format();
} else {
newStartDate = dayjs().utc().subtract(time, 's').format();
}
emit('changed', newStartDate, newEndDate);
}
temporaryCustomTimerEntry.value = '';
}
const currentTime = computed({
get() {
if (temporaryCustomTimerEntry.value !== '') {
return temporaryCustomTimerEntry.value;
}
return formatHumanReadableDuration(
calculateDifference(props.start, props.end)
);
},
// setter
set(newValue) {
if (newValue) {
temporaryCustomTimerEntry.value = newValue;
} else {
temporaryCustomTimerEntry.value = '';
}
},
});
function selectInput(event: Event) {
const target = event.target as HTMLInputElement;
target.select();
}
</script>
<template>
<input
data-testid="time_entry_duration_input"
class="text-white w-[100px] px-3 py-2 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-semibold"
@focus="selectInput"
@blur="updateTimerAndStartLiveTimerUpdate"
@keydown.enter="updateTimerAndStartLiveTimerUpdate"
v-model="currentTime" />
</template>
<style scoped></style>

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
import DaySectionHeader from '@/Components/Common/DaySectionHeader.vue';
import MainContainer from '@/Pages/MainContainer.vue';
defineProps<{
date: string;
}>();
</script>
<template>
<div
class="bg-card-background border-t border-b border-card-border py-1.5 text-sm">
<MainContainer>
<DaySectionHeader :date></DaySectionHeader>
</MainContainer>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,37 @@
<script setup lang="ts">
import TagDropdown from '@/Components/Common/Tag/TagDropdown.vue';
import { computed } from 'vue';
import TagBadge from '@/Components/Common/Tag/TagBadge.vue';
import type { Tag } from '@/utils/api';
import { useTagsStore } from '@/utils/useTags';
import { storeToRefs } from 'pinia';
const tagsStore = useTagsStore();
const { tags } = storeToRefs(tagsStore);
const emit = defineEmits(['changed']);
const model = defineModel<string[]>({
default: [],
});
const timeEntryTags = computed<Tag[]>(() => {
return tags.value.filter((tag) => model.value.includes(tag.id));
});
</script>
<template>
<TagDropdown @changed="emit('changed')" v-model="model">
<template #trigger>
<button data-testid="time_entry_tag_dropdown">
<TagBadge
:border="false"
size="large"
class="border-0"
:name="
timeEntryTags.map((tag) => tag.name).join(', ')
"></TagBadge>
</button>
</template>
</TagDropdown>
</template>
<style scoped></style>

View File

@@ -0,0 +1,58 @@
<script setup lang="ts">
import { computed } from 'vue';
import dayjs from 'dayjs';
const model = defineModel<string | null>({
default: null,
});
const hours = computed(() => {
return model.value ? dayjs(model.value).utc().hour() : null;
});
const minutes = computed(() => {
return model.value ? dayjs(model.value).utc().minute() : null;
});
function updateMinutes(event: Event) {
const target = event.target as HTMLInputElement;
const newValue = target.value;
if (parseInt(newValue)) {
model.value = dayjs(model.value)
.utc()
.set('minutes', parseInt(newValue))
.format();
}
}
function updateHours(event: Event) {
const target = event.target as HTMLInputElement;
const newValue = target.value;
if (parseInt(newValue)) {
model.value = dayjs(model.value)
.utc()
.set('hours', parseInt(newValue))
.format();
}
}
</script>
<template>
<div class="flex items-center justify-center">
<input
:value="hours"
@input="updateHours"
data-testid="time_picker_hour"
type="text"
class="bg-card-background border-none text-sm px-1 py-0.5 w-[30px] text-center focus:ring-0 focus:bg-card-background-active" />
<span>:</span>
<input
:value="minutes"
@input="updateMinutes"
data-testid="time_picker_minute"
type="text"
class="bg-card-background border-none text-sm px-1 py-0.5 w-[30px] text-center focus:ring-0 focus:bg-card-background-active" />
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,42 @@
<script setup lang="ts">
import TagDropdown from '@/Components/Common/Tag/TagDropdown.vue';
import { twMerge } from 'tailwind-merge';
import { TagIcon } from '@heroicons/vue/20/solid';
import { computed } from 'vue';
const emit = defineEmits(['changed']);
const model = defineModel({
default: [],
});
const iconColorClasses = computed(() => {
if (model.value.length > 0) {
return 'text-accent-200/80 focus:text-accent-200 hover:text-accent-200';
} else {
return 'text-icon-default hover:text-icon-active focus:text-icon-active';
}
});
</script>
<template>
<TagDropdown @changed="emit('changed')" v-model="model">
<template #trigger>
<button
data-testid="tag_dropdown"
:class="
twMerge(
iconColorClasses,
'flex-shrink-0 ring-0 focus:outline-none focus:ring-0 transition focus:bg-card-background-seperator hover:bg-card-background-seperator rounded-full w-11 h-11 flex items-center justify-center'
)
">
<TagIcon class="w-7 h-7"></TagIcon>
<span
v-if="model.length > 1"
class="font-extrabold absolute rounded-full text-xs w-3 h-3 block top-[15px] rotate-[45deg] right-[14px] text-card-background">
{{ model.length }}
</span>
</button>
</template>
</TagDropdown>
</template>
<style scoped></style>

View File

@@ -0,0 +1,89 @@
<script setup lang="ts">
import { twMerge } from 'tailwind-merge';
import { computed } from 'vue';
const emit = defineEmits(['changed']);
const props = withDefaults(
defineProps<{
size: 'base' | 'large';
active: boolean;
}>(),
{
size: 'base',
active: false,
}
);
const buttonSizeClasses = {
base: 'w-8 h-8 bg-accent-200/40 hover:scale-110 hover:bg-accent-300/70 ring-accent-200/10 focus:ring-accent-200/10 hover:ring-4',
large: 'w-11 h-11 ring-accent-200/10 focus:ring-accent-200/20 ring-8 hover:scale-110',
};
const iconClass = {
base: 'w-3.5 h-3.5',
large: 'w-4 h-4',
};
const buttonColorClasses = computed(() => {
if (props.active) {
return 'bg-red-400/80 hover:bg-red-500/80 focus:bg-red-500/80';
} else {
return 'bg-accent-300/50 hover:bg-accent-400/70 focus:bg-accent-400/70';
}
});
function toggleState() {
emit('changed', !props.active);
}
</script>
<template>
<button
@click="toggleState"
data-testid="timer_button"
:class="
twMerge(
buttonSizeClasses[size],
buttonColorClasses,
'flex items-center justify-center py-1 transition focus:outline-0 rounded-full text-white '
)
">
<Transition name="fade" mode="out-in">
<svg
v-if="props.active"
:class="iconClass[size]"
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M0.461426 2.74913C0.461426 1.48677 1.48666 0.461538 2.75076 0.461538H11.249C12.5131 0.461538 13.5383 1.48677 13.5383 2.75087V11.2491C13.5383 12.5132 12.5131 13.5385 11.249 13.5385H2.7525C2.4518 13.5387 2.154 13.4796 1.87614 13.3647C1.59828 13.2497 1.34582 13.0811 1.13319 12.8684C0.920559 12.6558 0.751936 12.4033 0.636968 12.1255C0.521999 11.8476 0.462941 11.5498 0.46317 11.2491V2.75262L0.461426 2.74913Z"
fill="currentColor" />
</svg>
<svg
v-else
:class="iconClass[size]"
viewBox="0 0 7 8"
fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M6.56167 3.18089C6.70764 3.26214 6.82926 3.38092 6.91393 3.52494C6.99859 3.66896 7.04324 3.83299 7.04324 4.00005C7.04324 4.16712 6.99859 4.33115 6.91393 4.47517C6.82926 4.61919 6.70764 4.73797 6.56167 4.81922L1.8925 7.41339C1.74982 7.49259 1.58895 7.53317 1.42578 7.53113C1.26261 7.52909 1.1028 7.48449 0.962147 7.40175C0.821497 7.31901 0.704879 7.20099 0.623826 7.05937C0.542772 6.91774 0.50009 6.7574 0.5 6.59422V1.40589C0.5 0.691721 1.2675 0.239221 1.8925 0.586721L6.56167 3.18089Z"
fill="currentColor" />
</svg>
</Transition>
</button>
</template>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -1,10 +1,10 @@
<script setup lang="ts">
import TimeTrackerStartStop from '@/Components/common/TimeTrackerStartStop.vue';
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
import { storeToRefs } from 'pinia';
import { computed } from 'vue';
import dayjs from 'dayjs';
import { formatHumanReadableDuration } from '@/utils/time';
import TimeTrackerStartStop from '@/Components/Common/TimeTrackerStartStop.vue';
const store = useCurrentTimeEntryStore();
const { currentTimeEntry, now, isActive } = storeToRefs(store);
const { onToggleButtonPress } = store;

View File

@@ -1,36 +1,22 @@
<script setup lang="ts">
import dayjs from 'dayjs';
defineProps<{
date: string;
duration: number;
}>();
import relativeTime from 'dayjs/plugin/relativeTime';
import isToday from 'dayjs/plugin/isToday';
import isYesterday from 'dayjs/plugin/isYesterday';
import { formatHumanReadableDuration } from '@/utils/time';
dayjs.extend(relativeTime);
dayjs.extend(isToday);
dayjs.extend(isYesterday);
function dayFormat(date: string) {
if (dayjs(date).isToday()) {
return 'Today';
} else if (dayjs(date).isYesterday()) {
return 'Yesterday';
}
return dayjs(date).fromNow();
}
import {
formatHumanReadableDate,
formatHumanReadableDuration,
} from '@/utils/time';
</script>
<template>
<div class="px-4 py-1.5 grid grid-cols-3">
<div class="px-3.5 py-2 flex @container">
<div class="flex items-center">
<p class="font-semibold text-sm text-white">
{{ dayFormat(date) }}
{{ formatHumanReadableDate(date) }}
</p>
</div>
<div class="flex items-center justify-center">
<div class="items-center justify-center flex-1 flex">
<svg
class="w-20 opacity-70 transition hover:opacity-100"
viewBox="0 0 42 10"
@@ -96,7 +82,8 @@ function dayFormat(date: string) {
fill-opacity="0.9" />
</svg>
</div>
<div class="flex items-center justify-center text-muted font-semibold">
<div
class="flex text-sm items-center justify-center text-muted font-semibold">
{{ formatHumanReadableDuration(duration) }}
</div>
</div>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import ProjectBadge from '@/Components/common/ProjectBadge.vue';
import TimeTrackerStartStop from '@/Components/common/TimeTrackerStartStop.vue';
import ProjectBadge from '@/Components/Common/Project/ProjectBadge.vue';
import TimeTrackerStartStop from '@/Components/Common/TimeTrackerStartStop.vue';
defineProps<{
title: string;
@@ -9,7 +9,7 @@ defineProps<{
</script>
<template>
<div class="px-4 py-2 grid grid-cols-5">
<div class="px-3.5 py-2 grid grid-cols-5">
<div class="col-span-4">
<p class="font-semibold text-white text-sm pb-1">
{{ title }}

View File

@@ -7,25 +7,31 @@ defineProps<{
</script>
<template>
<div class="px-4 py-3 grid grid-cols-3">
<div class="px-4 py-2 2xl:py-3">
<div class="col-span-2">
<p class="font-semibold text-sm text-white">
{{ name }}
</p>
<div class="flex justify-between">
<p class="font-semibold text-sm text-white">
{{ name }}
</p>
<div
v-if="working"
class="flex space-x-1.5 items-center justify-end">
<span
class="relative flex h-3 w-3 justify-center items-center">
<span
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-500 opacity-75"></span>
<span
class="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
</span>
<span
class="text-green-500 font-medium text-sm block pb-0.5">
working
</span>
</div>
</div>
<div class="text-muted text-sm font-medium">
{{ description }}
</div>
</div>
<div v-if="working" class="flex space-x-1.5 items-center justify-end">
<span class="relative flex h-3 w-3 justify-center items-center">
<span
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-500 opacity-75"></span>
<span
class="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
</span>
<span class="text-green-500 font-medium text-sm block pb-0.5">
working
</span>
</div>
</div>
</template>

View File

@@ -10,9 +10,9 @@ import {
} from 'echarts/components';
import VChart, { THEME_KEY } from 'vue-echarts';
import { provide, ref } from 'vue';
import StatCard from '@/Components/common/StatCard.vue';
import StatCard from '@/Components/Common/StatCard.vue';
import { ClockIcon } from '@heroicons/vue/20/solid';
import CardTitle from '@/Components/common/CardTitle.vue';
import CardTitle from '@/Components/Common/CardTitle.vue';
import LinearGradient from 'zrender/lib/graphic/LinearGradient';
import ProjectsChartCard from '@/Components/Dashboard/ProjectsChartCard.vue';
import { formatHumanReadableDuration } from '@/utils/time';
@@ -130,7 +130,7 @@ const option = ref({
</script>
<template>
<div class="grid gap-x-6 xl:gap-x-8 grid-cols-4">
<div class="grid gap-x-6 xl:gap-x-6 grid-cols-4">
<div class="col-span-3">
<CardTitle
title="This Week"

View File

@@ -21,13 +21,17 @@ const props = withDefaults(
}
);
const emit = defineEmits(['open']);
const emit = defineEmits(['open', 'submit']);
const open = defineModel({ default: false });
const closeOnEscape = (e: KeyboardEvent) => {
if (open.value && e.key === 'Escape') {
open.value = false;
}
if (open.value && e.key === 'Enter') {
emit('submit');
if (props.closeOnContentClick) open.value = false;
}
};
onMounted(() => document.addEventListener('keydown', closeOnEscape));
@@ -67,6 +71,11 @@ function toggleOpen() {
emit('open');
}
}
function onBackgroundClick() {
emit('submit');
open.value = false;
}
</script>
<template>
@@ -76,7 +85,10 @@ function toggleOpen() {
</div>
<!-- Full Screen Dropdown Overlay -->
<div v-show="open" class="fixed inset-0 z-40" @click="open = false" />
<div
v-show="open"
class="fixed inset-0 z-40"
@click="onBackgroundClick" />
<transition
enter-active-class="transition ease-out duration-200"

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import type { Component } from 'vue';
import { Link } from '@inertiajs/vue3';
defineProps<{
title: string;
@@ -11,13 +12,13 @@ defineProps<{
<template>
<li>
<a
<Link
:href="href"
:class="[
current
? 'bg-menu-active text-white'
: 'text-indigo-200 hover:text-white hover:bg-menu-active',
'group flex gap-x-3 rounded-md px-3 py-2 transition leading-6 font-medium',
: 'text-muted hover:text-white hover:bg-menu-active ',
'group flex gap-x-2 rounded-md px-2 py-1.5 transition leading-6 font-semibold text-sm items-center',
]">
<component
:is="icon"
@@ -25,10 +26,10 @@ defineProps<{
current
? 'text-icon-active'
: 'text-icon-default group-hover:text-icon-active',
'transition h-6 w-6 shrink-0',
'transition h-5 w-5 shrink-0',
]"
aria-hidden="true" />
{{ title }}
</a>
</Link>
</li>
</template>

View File

@@ -1,10 +1,9 @@
<script setup lang="ts">
import { ClockIcon } from '@heroicons/vue/20/solid';
import CardTitle from '@/Components/common/CardTitle.vue';
import BillableToggleButton from '@/Components/common/BillableToggleButton.vue';
import TimeTrackerStartStop from '@/Components/common/TimeTrackerStartStop.vue';
import TagDropdown from '@/Components/common/TagDropdown.vue';
import ProjectDropdown from '@/Components/common/ProjectDropdown.vue';
import CardTitle from '@/Components/Common/CardTitle.vue';
import BillableToggleButton from '@/Components/Common/BillableToggleButton.vue';
import TimeTrackerStartStop from '@/Components/Common/TimeTrackerStartStop.vue';
import ProjectDropdown from '@/Components/Common/Project/ProjectDropdown.vue';
import { usePage } from '@inertiajs/vue3';
import { type User } from '@/types/models';
import { computed, onMounted, ref, watch } from 'vue';
@@ -14,8 +13,8 @@ import duration from 'dayjs/plugin/duration';
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
import { storeToRefs } from 'pinia';
import type { Project } from '@/utils/useProjects';
import parse from 'parse-duration';
import TimeTrackerTagDropdown from '@/Components/Common/TimeTracker/TimeTrackerTagDropdown.vue';
const page = usePage<{
auth: {
@@ -70,16 +69,6 @@ onMounted(async () => {
}
});
const currentProject = ref<Project>();
watch(currentProject, () => {
if (currentProject.value) {
currentTimeEntry.value.project_id = currentProject.value.id;
if (isActive.value) {
useCurrentTimeEntryStore().updateTimer();
}
}
});
function updateTimeEntry() {
if (currentTimeEntry.value.id) {
useCurrentTimeEntryStore().updateTimer();
@@ -122,12 +111,14 @@ function updateTimerAndStartLiveTimerUpdate() {
type="text" />
</div>
<div class="flex items-center">
<ProjectDropdown v-model="currentProject"></ProjectDropdown>
<ProjectDropdown
@changed="updateTimeEntry"
v-model="currentTimeEntry.project_id"></ProjectDropdown>
</div>
<div class="flex items-center space-x-2 px-4">
<TagDropdown
<TimeTrackerTagDropdown
@changed="updateTimeEntry"
v-model="currentTimeEntry.tags"></TagDropdown>
v-model="currentTimeEntry.tags"></TimeTrackerTagDropdown>
<BillableToggleButton></BillableToggleButton>
</div>
<div class="border-l border-card-border">

View File

@@ -1,220 +0,0 @@
<script setup lang="ts">
import { PlusCircleIcon, TagIcon } from '@heroicons/vue/20/solid';
import Dropdown from '@/Components/Dropdown.vue';
import {
type Component,
computed,
nextTick,
onMounted,
ref,
watch,
watchEffect,
} from 'vue';
import TagDropdownItem from '@/Components/common/TagDropdownItem.vue';
import { twMerge } from 'tailwind-merge';
import {
ComboboxAnchor,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxRoot,
ComboboxViewport,
} from 'radix-vue';
import { useTagsStore } from '@/utils/useTags';
import { storeToRefs } from 'pinia';
const tagsStore = useTagsStore();
const { tags } = storeToRefs(tagsStore);
const emit = defineEmits(['changed']);
const model = defineModel<string[]>({
default: [],
});
onMounted(async () => {
await tagsStore.fetchTags();
});
const searchInput = ref<Component | null>(null);
const open = ref(false);
const dropdownViewport = ref<Component | null>(null);
const searchValue = ref('');
function isTagSelected(id: string) {
return model.value.includes(id);
}
function addOrRemoveTagFromSelection(id: string) {
if (model.value.includes(id)) {
model.value = model.value.filter((tagId) => tagId !== id);
} else {
model.value.push(id);
}
emit('changed');
}
const iconColorClasses = computed(() => {
if (model.value.length > 0) {
return 'text-accent-200/80 focus:text-accent-200 hover:text-accent-200';
} else {
return 'text-icon-default hover:text-icon-active focus:text-icon-active';
}
});
watch(open, (isOpen) => {
if (isOpen) {
nextTick(() => {
// @ts-expect-error We need to access the actual HTML Element to focus as radix-vue does not support any other way right now
searchInput.value?.$el?.focus();
});
tags.value.sort((a) => {
return model.value.includes(a.id) ? -1 : 1;
});
}
});
const filteredTags = computed(() => {
return tags.value.filter((tag) => {
return tag.name
.toLowerCase()
.includes(searchValue.value?.toLowerCase()?.trim() || '');
});
});
const showAllTags = ref(false);
const shownTags = computed(() => {
if (showAllTags.value) {
return filteredTags.value;
} else {
return filteredTags.value.slice(0, 5);
}
});
const moreTagsAvailable = computed(() => {
return filteredTags.value.length - shownTags.value.length;
});
async function addTagIfNoneExists() {
if (searchValue.value.length > 0 && filteredTags.value.length === 0) {
const newTag = await tagsStore.createTag(searchValue.value);
addOrRemoveTagFromSelection(newTag.id);
searchValue.value = '';
}
}
function removeTagLimit() {
showAllTags.value = true;
}
watchEffect(() => {
if (searchValue.value === ' ') {
nextTick(() => {
searchValue.value = '';
const currentSelectedItem =
// @ts-expect-error We need to access the actual HTML Element to focus as radix-vue does not support any other way right now
dropdownViewport.value?.$el?.querySelector(
'[data-highlighted]'
);
const highlightedTagId = currentSelectedItem?.getAttribute(
'data-tag-id'
) as string;
if (highlightedTagId) {
const highlightedTag = tags.value.find(
(tag) => tag.id === highlightedTagId
);
if (highlightedTag) {
addOrRemoveTagFromSelection(highlightedTag.id);
}
}
});
}
});
function updateValue(e: string[]) {
model.value = e;
emit('changed');
}
</script>
<template>
<Dropdown width="120" v-model="open" :closeOnContentClick="false">
<template #trigger>
<button
data-testid="tag_dropdown"
:class="
twMerge(
iconColorClasses,
'flex-shrink-0 ring-0 focus:outline-none focus:ring-0 transition focus:bg-card-background-seperator hover:bg-card-background-seperator rounded-full w-11 h-11 flex items-center justify-center'
)
">
<TagIcon class="w-7 h-7"></TagIcon>
<div
v-if="model.length > 1"
class="font-extrabold absolute rounded-full text-xs w-3 h-3 block top-[15px] rotate-[45deg] right-[14px] text-card-background">
{{ model.length }}
</div>
</button>
</template>
<template #content>
<ComboboxRoot
multiple
:open="open"
@update:modelValue="updateValue as any"
v-model:searchTerm="searchValue"
class="relative">
<ComboboxAnchor>
<ComboboxInput
@keydown.enter="addTagIfNoneExists"
data-testid="tag_dropdown_search"
ref="searchInput"
class="bg-card-background border-0 placeholder-muted text-sm text-white py-2.5 focus:ring-0 border-b border-card-background-seperator focus:border-card-background-seperator w-full"
placeholder="Search for a tag..." />
</ComboboxAnchor>
<ComboboxContent>
<ComboboxViewport ref="dropdownViewport" class="w-60">
<ComboboxEmpty>
<div
v-if="searchValue.length > 0"
class="bg-card-background-active">
<div
class="flex space-x-3 items-center px-4 py-3 text-xs font-medium border-t rounded-b-lg border-card-background-seperator">
<PlusCircleIcon
class="w-5 flex-shrink-0"></PlusCircleIcon>
<span
>Add "{{ searchValue }}" as a new
Tag</span
>
</div>
</div>
<div v-else></div>
</ComboboxEmpty>
<ComboboxItem
v-for="tag in shownTags"
:key="tag.id"
:value="tag.id"
class="data-[highlighted]:bg-card-background-active"
data-testid="tag_dropdown_entries"
:data-tag-id="tag.id">
<TagDropdownItem
:selected="isTagSelected(tag.id)"
:name="tag.name"></TagDropdownItem>
</ComboboxItem>
</ComboboxViewport>
<button
@click="removeTagLimit"
v-if="moreTagsAvailable > 0"
class="border-t hover:text-white hover:bg-card-background-active px-2 text-center font-semibold py-2 border-t-card-background-seperator">
Show all
</button>
</ComboboxContent>
</ComboboxRoot>
</template>
</Dropdown>
</template>
<style scoped></style>

View File

@@ -16,16 +16,25 @@ import {
} from '@heroicons/vue/20/solid';
import NavigationSidebarItem from '@/Components/NavigationSidebarItem.vue';
import UserSettingsIcon from '@/Components/UserSettingsIcon.vue';
import MainContainer from '@/Pages/MainContainer.vue';
import { onMounted } from 'vue';
import { useProjectsStore } from '@/utils/useProjects';
import { useTagsStore } from '@/utils/useTags';
defineProps({
title: String,
});
onMounted(async () => {
await useProjectsStore().fetchProjects();
await useTagsStore().fetchTags();
});
</script>
<template>
<div class="flex flex-wrap bg-default-background text-muted">
<div
class="flex-shrink-0 h-screen fixed w-[250px] xl:w-[300px] px-2.5 xl:px-4 py-4 flex flex-col justify-between">
class="flex-shrink-0 h-screen fixed w-[230px] 2xl:w-[270px] px-2.5 2xl:px-4 py-4 flex flex-col justify-between">
<div>
<div class="border-b border-default-background-seperator pb-2">
<OrganizationSwitcher></OrganizationSwitcher>
@@ -45,7 +54,8 @@ defineProps({
<NavigationSidebarItem
title="Time"
:icon="ClockIcon"
:href="route('dashboard')"></NavigationSidebarItem>
:current="route().current('time')"
:href="route('time')"></NavigationSidebarItem>
<NavigationSidebarItem
title="Reporting"
:icon="ChartBarIcon"
@@ -53,9 +63,7 @@ defineProps({
</ul>
</nav>
<div class="text-muted font-semibold text-sm pt-6 pb-4">
Manage
</div>
<div class="text-muted text-sm font-bold pt-6 pb-4">Manage</div>
<nav>
<ul class="space-y-1">
@@ -93,7 +101,7 @@ defineProps({
<UserSettingsIcon></UserSettingsIcon>
</ul>
</div>
<div class="flex-1 ml-[250px] xl:ml-[300px]">
<div class="flex-1 ml-[230px] 2xl:ml-[270px]">
<Head :title="title" />
<Banner />
@@ -104,8 +112,10 @@ defineProps({
<header
v-if="$slots.header"
class="bg-default-background border-b border-default-background-seperator shadow">
<div class="py-6 px-4 sm:px-6 lg:px-8">
<slot name="header" />
<div class="pt-8 pb-3">
<MainContainer>
<slot name="header" />
</MainContainer>
</div>
</header>

View File

@@ -5,28 +5,9 @@ import RecentlyTrackedTasksCard from '@/Components/Dashboard/RecentlyTrackedTask
import LastSevenDaysCard from '@/Components/Dashboard/LastSevenDaysCard.vue';
import TeamActivityCard from '@/Components/Dashboard/TeamActivityCard.vue';
import ThisWeekOverview from '@/Components/Dashboard/ThisWeekOverview.vue';
import { usePage } from '@inertiajs/vue3';
import type { Organization, User } from '@/types/models';
import { onMounted } from 'vue';
import { useProjectsStore } from '@/utils/useProjects';
import ActivityGraphCard from '@/Components/Dashboard/ActivityGraphCard.vue';
import MainContainer from '@/Pages/MainContainer.vue';
const page = usePage<{
auth: {
user: User & {
all_teams: Organization[];
};
};
}>();
onMounted(async () => {
if (page.props.auth.user.current_team_id) {
await useProjectsStore().fetchProjects(
page.props.auth.user.current_team_id
);
}
});
const props = defineProps<{
latestTasks: {
id: string;
@@ -73,7 +54,7 @@ const props = defineProps<{
<TimeTracker></TimeTracker>
</MainContainer>
<MainContainer
class="grid gap-x-6 xl:gap-x-8 grid-cols-4 pt-5 pb-6 border-b border-default-background-seperator items-stretch">
class="grid gap-x-6 grid-cols-2 xl:grid-cols-4 pt-5 pb-6 border-b border-default-background-seperator items-stretch">
<RecentlyTrackedTasksCard
:latestTasks="props.latestTasks"></RecentlyTrackedTasksCard>
<LastSevenDaysCard

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"></script>
<template>
<div class="sm:px-6 lg:px-8 xl:px-10 2xl:px-12 mx-auto">
<div class="sm:px-6 lg:px-8 3xl:px-12 mx-auto">
<slot></slot>
</div>
</template>

View File

@@ -0,0 +1,47 @@
<script setup lang="ts">
import AppLayout from '@/Layouts/AppLayout.vue';
import TimeTracker from '@/Components/TimeTracker.vue';
import { computed, onMounted } from 'vue';
import MainContainer from '@/Pages/MainContainer.vue';
import { useTimeEntriesStore } from '@/utils/useTimeEntries';
import { storeToRefs } from 'pinia';
import dayjs from 'dayjs';
import type { TimeEntry } from '@/utils/api';
import TimeEntryRowHeading from '@/Components/Common/TimeEntry/TimeEntryRowHeading.vue';
import TimeEntryRow from '@/Components/Common/TimeEntry/TimeEntryRow.vue';
const timeEntriesStore = useTimeEntriesStore();
const { timeEntries } = storeToRefs(timeEntriesStore);
onMounted(async () => {
await timeEntriesStore.fetchTimeEntries();
});
const groupedTimeEntries = computed(() => {
const groupedEntries: Record<string, TimeEntry[]> = {};
for (const entry of timeEntries.value) {
const oldEntries =
groupedEntries[dayjs(entry.start).utc().format('YYYY-MM-DD')];
const newEntries = [...(oldEntries ?? []), entry];
groupedEntries[dayjs(entry.start).utc().format('YYYY-MM-DD')] =
newEntries;
}
return groupedEntries;
});
</script>
<template>
<AppLayout title="Dashboard" data-testid="dashboard_view">
<MainContainer
class="py-8 border-b border-default-background-seperator">
<TimeTracker></TimeTracker>
</MainContainer>
<div v-for="(value, key) in groupedTimeEntries" :key="key">
<TimeEntryRowHeading :date="key"></TimeEntryRowHeading>
<TimeEntryRow
:key="entry.id"
v-for="entry in value"
:time-entry="entry"></TimeEntryRow>
</div>
</AppLayout>
</template>

View File

@@ -74,27 +74,6 @@ export interface Task {
organization: Organization;
}
export interface TimeEntry {
// columns
id: string;
description: string;
start: string;
end: string | null;
billable: boolean;
user_id: string;
organization_id: string;
project_id: string | null;
task_id: string | null;
tags: string[] | null;
created_at: string | null;
updated_at: string | null;
// relations
user: User;
organization: Organization;
project: Project;
task: Task;
}
export interface User {
// columns
id: string;

View File

@@ -1,4 +1,19 @@
import type { ApiOf } from '@zodios/core';
import type { ApiOf, ZodiosResponseByAlias } from '@zodios/core';
import { api } from '../../../openapi.json.client';
export type SolidTimeApi = ApiOf<typeof api>;
export type TimeEntryResponse = ZodiosResponseByAlias<
SolidTimeApi,
'getTimeEntries'
>;
export type TimeEntry = TimeEntryResponse['data'][0];
export type ProjectResponse = ZodiosResponseByAlias<
SolidTimeApi,
'getProjects'
>;
export type Project = ProjectResponse['data'][0];
export type TagIndexResponse = ZodiosResponseByAlias<SolidTimeApi, 'getTags'>;
export type Tag = TagIndexResponse['data'][0];

View File

@@ -1,8 +1,39 @@
import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
import relativeTime from 'dayjs/plugin/relativeTime';
import isToday from 'dayjs/plugin/isToday';
import isYesterday from 'dayjs/plugin/isYesterday';
import utc from 'dayjs/plugin/utc';
dayjs.extend(relativeTime);
dayjs.extend(isToday);
dayjs.extend(isYesterday);
dayjs.extend(duration);
dayjs.extend(utc);
export function formatHumanReadableDuration(duration: number): string {
return dayjs.duration(duration, 's').format('HH[h] mm[min]');
}
export function calculateDifference(start: string, end: string | null) {
if (end === null) {
end = dayjs().utc().format();
}
return dayjs(end).diff(dayjs(start), 'second');
}
export function formatTime(date: string) {
return dayjs(date).utc().format('HH:mm');
}
export function formatDate(date: string): string {
return dayjs(date).utc().format('DD.MM.YYYY');
}
export function formatHumanReadableDate(date: string) {
if (dayjs(date).isToday()) {
return 'Today';
} else if (dayjs(date).isYesterday()) {
return 'Yesterday';
}
return dayjs(date).fromNow();
}

View File

@@ -1,16 +1,15 @@
import { defineStore } from 'pinia';
import { computed, reactive, ref } from 'vue';
import { api } from '../../../openapi.json.client';
import type { ZodiosResponseByAlias } from '@zodios/core';
import type { SolidTimeApi } from '@/utils/api';
import type { TimeEntry } from '@/utils/api';
import dayjs, { Dayjs } from 'dayjs';
import utc from 'dayjs/plugin/utc';
import { getCurrentOrganizationId, getCurrentUserId } from '@/utils/useUser';
import { useLocalStorage } from '@vueuse/core';
import { useTimeEntriesStore } from '@/utils/useTimeEntries';
dayjs.extend(utc);
type TimeEntryResponse = ZodiosResponseByAlias<SolidTimeApi, 'getTimeEntries'>;
export type TimeEntry = TimeEntryResponse['data'][0];
const emptyTimeEntry = {
id: '',
description: null,
@@ -21,11 +20,16 @@ const emptyTimeEntry = {
task_id: null,
project_id: null,
tags: [],
billable: false,
} as TimeEntry;
export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
const currentTimeEntry = ref<TimeEntry>(reactive(emptyTimeEntry));
useLocalStorage('solidtime/current-time-entry', currentTimeEntry, {
deep: true,
});
function $reset() {
currentTimeEntry.value = { ...emptyTimeEntry };
}
@@ -84,6 +88,9 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
user_id: user,
start: startTime,
description: currentTimeEntry.value?.description,
project_id: currentTimeEntry.value?.project_id,
billable: currentTimeEntry.value.billable,
tags: currentTimeEntry.value?.tags,
},
{ params: { organization: organization } }
);
@@ -131,6 +138,7 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
user_id: user,
project_id: currentTimeEntry.value.project_id,
start: currentTimeEntry.value.start,
billable: currentTimeEntry.value.billable,
end: null,
tags: currentTimeEntry.value.tags,
},
@@ -168,6 +176,7 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
stopLiveTimer();
await stopTimer();
}
useTimeEntriesStore().fetchTimeEntries();
}
startLiveTimer();

View File

@@ -1,24 +1,26 @@
import { defineStore } from 'pinia';
import { api } from '../../../openapi.json.client';
import { computed, ref } from 'vue';
import type { ZodiosResponseByAlias } from '@zodios/core';
import type { SolidTimeApi } from '@/utils/api';
type ProjectResponse = ZodiosResponseByAlias<SolidTimeApi, 'getProjects'>;
export type Project = ProjectResponse['data'][0];
import type { Project, ProjectResponse } from '@/utils/api';
import { getCurrentOrganizationId } from '@/utils/useUser';
export const useProjectsStore = defineStore('projects', () => {
const projectResponse = ref<ProjectResponse | null>(null);
async function fetchProjects(organizationId: string) {
projectResponse.value = await api.getProjects({
params: {
organization: organizationId,
},
});
async function fetchProjects() {
const organization = getCurrentOrganizationId();
if (organization) {
projectResponse.value = await api.getProjects({
params: {
organization: organization,
},
});
}
}
const projects = computed(() => projectResponse.value?.data || []);
const projects = computed<Project[]>(
() => projectResponse.value?.data || []
);
return { projects, fetchProjects };
});

View File

@@ -1,13 +1,9 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import type { ZodiosResponseByAlias } from '@zodios/core';
import type { SolidTimeApi } from '@/utils/api';
import type { Tag } from '@/utils/api';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { api } from '../../../openapi.json.client';
type TagIndexResponse = ZodiosResponseByAlias<SolidTimeApi, 'getTags'>;
export type Tag = TagIndexResponse['data'][0];
export const useTagsStore = defineStore('tags', () => {
const tags = ref<Tag[]>([]);

View File

@@ -0,0 +1,69 @@
import { defineStore } from 'pinia';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { api } from '../../../openapi.json.client';
import { reactive, ref } from 'vue';
import type { TimeEntry } from '@/utils/api';
export const useTimeEntriesStore = defineStore('timeEntries', () => {
const timeEntries = ref<TimeEntry[]>(reactive([]));
async function fetchTimeEntries() {
const organizationId = getCurrentOrganizationId();
if (organizationId) {
const timeEntriesResponse = await api.getTimeEntries({
params: {
organization: organizationId,
},
});
timeEntries.value = timeEntriesResponse.data;
}
}
async function updateTimeEntry(timeEntry: TimeEntry) {
const organizationId = getCurrentOrganizationId();
if (organizationId) {
await api.updateTimeEntry(timeEntry, {
params: {
organization: organizationId,
timeEntry: timeEntry.id,
},
});
}
}
async function createTimeEntry(timeEntry: TimeEntry) {
const organizationId = getCurrentOrganizationId();
if (organizationId) {
await api.createTimeEntry(timeEntry, {
params: {
organization: organizationId,
},
});
await fetchTimeEntries();
}
}
async function deleteTimeEntry(timeEntryId: string) {
const organizationId = getCurrentOrganizationId();
if (organizationId) {
await api.deleteTimeEntry(
{},
{
params: {
organization: organizationId,
timeEntry: timeEntryId,
},
}
);
await fetchTimeEntries();
}
}
return {
timeEntries,
fetchTimeEntries,
updateTimeEntry,
createTimeEntry,
deleteTimeEntry,
};
});

View File

@@ -33,4 +33,9 @@ Route::middleware([
'verified',
])->group(function () {
Route::get('/dashboard', [DashboardController::class, 'dashboard'])->name('dashboard');
Route::get('/time', function () {
return Inertia::render('Time');
})->name('time');
});

View File

@@ -54,5 +54,5 @@ export default {
},
},
plugins: [forms, typography],
plugins: [forms, typography, require('@tailwindcss/container-queries')],
};