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

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>