Merge branch 'main' of github.com:jcalixte/loopycode
2
.npmrc
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
auto-install-peers=true
|
||||||
|
strict-peer-dependencies=false
|
||||||
@@ -5,9 +5,7 @@
|
|||||||
## TODO
|
## TODO
|
||||||
|
|
||||||
- add task when recording,
|
- add task when recording,
|
||||||
- modify a task,
|
|
||||||
- find a way to prevent desync between tasks and records (when modifying steps of a task for example),
|
- find a way to prevent desync between tasks and records (when modifying steps of a task for example),
|
||||||
- easily copy steps,
|
- easily copy steps,
|
||||||
- 🐛 changing task while recording breaks the controls
|
|
||||||
|
|
||||||
[Loop icons created by Dreamstale - Flaticon](https://www.flaticon.com/free-icons/loop)
|
[Loop icons created by Dreamstale - Flaticon](https://www.flaticon.com/free-icons/loop)
|
||||||
|
|||||||
13
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "loopycode",
|
"name": "loopycode",
|
||||||
"version": "0.1.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -15,8 +15,8 @@
|
|||||||
"format": "prettier --write src/"
|
"format": "prettier --write src/"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vueuse/core": "^10.1.0",
|
"@vueuse/core": "^10.1.2",
|
||||||
"@vueuse/math": "^10.1.0",
|
"@vueuse/math": "^10.1.2",
|
||||||
"bulma": "^0.9.4",
|
"bulma": "^0.9.4",
|
||||||
"nanoid": "^4.0.2",
|
"nanoid": "^4.0.2",
|
||||||
"pinia": "^2.0.35",
|
"pinia": "^2.0.35",
|
||||||
@@ -32,10 +32,10 @@
|
|||||||
"@types/jsdom": "^21.1.1",
|
"@types/jsdom": "^21.1.1",
|
||||||
"@types/node": "^18.14.2",
|
"@types/node": "^18.14.2",
|
||||||
"@vitejs/plugin-vue": "^4.1.0",
|
"@vitejs/plugin-vue": "^4.1.0",
|
||||||
"@vitest/browser": "^0.30.1",
|
"@vitest/browser": "^0.31.0",
|
||||||
"@vitest/ui": "^0.30.1",
|
"@vitest/ui": "^0.31.0",
|
||||||
"@vue/eslint-config-prettier": "^7.1.0",
|
"@vue/eslint-config-prettier": "^7.1.0",
|
||||||
"@vue/eslint-config-typescript": "^11.0.2",
|
"@vue/eslint-config-typescript": "^11.0.3",
|
||||||
"@vue/test-utils": "^2.3.2",
|
"@vue/test-utils": "^2.3.2",
|
||||||
"@vue/tsconfig": "^0.1.3",
|
"@vue/tsconfig": "^0.1.3",
|
||||||
"eslint": "^8.39.0",
|
"eslint": "^8.39.0",
|
||||||
@@ -46,6 +46,7 @@
|
|||||||
"sass": "^1.62.1",
|
"sass": "^1.62.1",
|
||||||
"typescript": "~4.8.4",
|
"typescript": "~4.8.4",
|
||||||
"vite": "^4.3.1",
|
"vite": "^4.3.1",
|
||||||
|
"vite-plugin-pwa": "^0.14.7",
|
||||||
"vitest": "^0.30.1",
|
"vitest": "^0.30.1",
|
||||||
"vue-tsc": "^1.4.4",
|
"vue-tsc": "^1.4.4",
|
||||||
"webdriverio": "^8.8.8"
|
"webdriverio": "^8.8.8"
|
||||||
|
|||||||
2857
pnpm-lock.yaml
generated
BIN
public/favicon/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
public/favicon/android-chrome-maskable-192x192.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
public/favicon/android-chrome-maskable-512x512.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
public/favicon/apple-touch-icon-120x120.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
public/favicon/apple-touch-icon-152x152.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
public/favicon/apple-touch-icon-180x180.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
public/favicon/apple-touch-icon-60x60.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
public/favicon/apple-touch-icon-76x76.png
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
BIN
public/favicon/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
public/favicon/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 931 B |
BIN
public/favicon/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
public/favicon/favicon.ico
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
public/favicon/msapplication-icon-144x144.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
public/favicon/mstile-150x150.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
3
public/favicon/safari-pinned-tab.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512" version="1.1">
|
||||||
|
<path d="M 158.500 0.599 C 128.075 3.635, 104.559 11.310, 80.247 26.141 C 60.092 38.436, 38.532 60, 26.151 80.247 C 13.715 100.584, 5.408 123.070, 1.861 146 C 0.214 156.645, 0.234 184.895, 1.896 195.500 C 10.665 251.454, 44.686 298.153, 95.233 323.619 C 104.716 328.397, 107.230 329.051, 111.533 327.863 C 115.425 326.789, 118.990 322.174, 118.996 318.200 C 119.005 312.904, 116.390 310.424, 104.045 304.024 C 88.984 296.214, 77.982 288.367, 66.763 277.432 C 34.877 246.352, 18.632 203.690, 21.987 159.835 C 24.836 122.588, 38.919 91.980, 65.434 65.409 C 83.008 47.798, 99.795 37.194, 122 29.678 C 176.945 11.080, 235.813 24.833, 276.578 65.792 C 300.513 89.842, 315.205 120.852, 318.944 155.211 C 319.597 161.214, 320 195.763, 320 245.703 L 320 326.484 296.250 302.790 C 283.188 289.758, 271.198 278.610, 269.606 278.017 C 265.483 276.482, 260.342 278.391, 257.926 282.353 C 255.640 286.103, 255.494 289.138, 257.423 292.800 C 258.206 294.285, 273.844 310.404, 292.173 328.620 C 327.482 363.711, 328.186 364.258, 334.500 361.514 C 336.150 360.797, 352.688 344.960, 371.250 326.321 C 404.534 292.900, 405 292.376, 405 288.351 C 405 280.980, 397.906 275.593, 391.394 278.017 C 389.802 278.610, 377.813 289.758, 364.750 302.790 L 341 326.484 341 241.231 C 341 150.245, 340.927 148.641, 335.888 128.500 C 319.590 63.356, 264.821 13.277, 198.036 2.451 C 189.171 1.014, 165.470 -0.096, 158.500 0.599 M 178.500 150.156 C 176.401 150.704, 165.214 161.244, 141.250 185.253 C 108.241 218.324, 107 219.705, 107 223.379 C 107 230.418, 113.036 235.432, 120 234.177 C 121.598 233.888, 131.248 225.044, 146.750 209.658 L 171 185.591 171 270.302 C 171 360.545, 171.156 364.028, 176.062 383.243 C 191.606 444.124, 239.735 491.944, 300.500 506.883 C 314.609 510.352, 324.110 511.398, 341.500 511.398 C 350.850 511.398, 362.100 510.825, 366.500 510.124 C 415.668 502.294, 459.024 474.012, 485.498 432.500 C 497.413 413.818, 506.459 389.568, 510.119 366.500 C 511.775 356.063, 511.788 326.687, 510.140 316.500 C 500.936 259.581, 467.324 213.634, 416.253 188.158 C 411.989 186.031, 407.266 183.992, 405.758 183.628 C 399.937 182.222, 393 187.861, 393 194 C 393 198.993, 395.791 201.736, 406.229 207.003 C 455.615 231.921, 485.987 276.731, 489.914 330.470 C 495.216 403.024, 447.937 468.377, 377.500 485.855 C 353.979 491.691, 326.459 491.579, 303.197 485.551 C 251.807 472.235, 210.085 430.251, 196.892 378.578 C 192.318 360.662, 192.040 354.333, 192.020 267.527 L 192 185.554 216.297 209.777 C 240.663 234.069, 241.434 234.669, 247.427 234.022 C 253.192 233.400, 257.043 226.691, 255.069 220.708 C 253.612 216.295, 187.608 150.455, 184 149.816 C 182.625 149.572, 180.150 149.725, 178.500 150.156" stroke="none" fill="white" fill-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.8 KiB |
6
public/icons/edit.svg
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-edit" width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" stroke="#4d70cb" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||||
|
<path d="M9 7h-3a2 2 0 0 0 -2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2 -2v-3" />
|
||||||
|
<path d="M9 15h3l8.5 -8.5a1.5 1.5 0 0 0 -3 -3l-8.5 8.5v3" />
|
||||||
|
<line x1="16" y1="5" x2="19" y2="8" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 459 B |
2
public/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
@@ -23,13 +23,13 @@ const getNextStepId = () => {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!recordStore.currentStepId) {
|
if (!record.value.currentStepId) {
|
||||||
const [firstStep] = task.value.steps
|
const [firstStep] = task.value.steps
|
||||||
return firstStep.id
|
return firstStep.id
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentStepIndex = task.value.steps.findIndex(
|
const currentStepIndex = task.value.steps.findIndex(
|
||||||
(step) => step.id === recordStore.currentStepId
|
(step) => step.id === record.value.currentStepId
|
||||||
)
|
)
|
||||||
|
|
||||||
const canHaveNextIndex =
|
const canHaveNextIndex =
|
||||||
@@ -42,10 +42,12 @@ const getNextStepId = () => {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasStarted = computed(
|
||||||
|
() => Object.values(record.value?.stepRecords ?? {}).length > 0
|
||||||
|
)
|
||||||
|
|
||||||
const canStart = computed(
|
const canStart = computed(
|
||||||
() =>
|
() => !record.value.currentStepId && !hasStarted.value
|
||||||
!recordStore.currentStepId &&
|
|
||||||
Object.values(record.value?.stepRecords ?? {}).length === 0
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const startRecording = () => {
|
const startRecording = () => {
|
||||||
@@ -61,13 +63,13 @@ const startRecording = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const nextStep = () => {
|
const nextStep = () => {
|
||||||
if (!task.value || !recordStore.currentStepId || !record.value) {
|
if (!task.value || !record.value.currentStepId || !record.value) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
recordStore.nextStepRecord({
|
recordStore.nextStepRecord({
|
||||||
taskId: record.value.taskId,
|
taskId: record.value.taskId,
|
||||||
currentStepId: recordStore.currentStepId,
|
currentStepId: record.value.currentStepId,
|
||||||
nextStepId: getNextStepId(),
|
nextStepId: getNextStepId(),
|
||||||
tick: toISODate(new Date())
|
tick: toISODate(new Date())
|
||||||
})
|
})
|
||||||
@@ -107,48 +109,54 @@ onUnmounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="record-controls buttons has-addons">
|
<div class="columns record-controls">
|
||||||
<template v-if="record && recordStore.currentStepId">
|
<div class="column buttons has-addons">
|
||||||
<button
|
<template v-if="record && record.currentStepId">
|
||||||
class="button is-primary is-light"
|
<button
|
||||||
v-if="record.breakTime"
|
class="button is-primary is-light"
|
||||||
@click="recordStore.resume(taskId)"
|
v-if="record.breakTime"
|
||||||
>
|
@click="recordStore.resume(taskId)"
|
||||||
<img src="/icons/start.svg" alt="resume" />
|
>
|
||||||
</button>
|
<img src="/icons/start.svg" alt="resume" />
|
||||||
<button
|
</button>
|
||||||
class="button is-primary is-light"
|
<button
|
||||||
v-else
|
class="button is-primary is-light"
|
||||||
@click="recordStore.pause(taskId)"
|
v-else
|
||||||
>
|
@click="recordStore.pause(taskId)"
|
||||||
<img src="/icons/pause.svg" alt="pause" />
|
>
|
||||||
</button>
|
<img src="/icons/pause.svg" alt="pause" />
|
||||||
</template>
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template v-if="!record || !record.end">
|
<template v-if="!record || !record.end">
|
||||||
<button
|
<button
|
||||||
v-if="canStart"
|
v-if="canStart"
|
||||||
@click="startRecording"
|
@click="startRecording"
|
||||||
class="button is-primary is-light"
|
class="button is-primary is-light"
|
||||||
>
|
>
|
||||||
<img src="/icons/start.svg" alt="start" />
|
<img src="/icons/start.svg" alt="start" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="button is-primary is-light"
|
class="button is-primary is-light"
|
||||||
v-else-if="!record?.breakTime"
|
v-else-if="!record?.breakTime"
|
||||||
@click="nextStep"
|
@click="nextStep"
|
||||||
>
|
>
|
||||||
<img src="/icons/next.svg" alt="next" />
|
<img src="/icons/next.svg" alt="next" />
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<button class="button is-warning" @click="recordStore.reset(taskId)">
|
<button
|
||||||
<img src="/icons/recycle.svg" alt="reset" />
|
v-if="hasStarted"
|
||||||
</button>
|
class="button is-warning"
|
||||||
</div>
|
@click="recordStore.reset(taskId)"
|
||||||
<div class="message">
|
>
|
||||||
<p><kbd>s</kbd>: start record</p>
|
<img src="/icons/recycle.svg" alt="reset" />
|
||||||
<p><kbd>n</kbd>: next step</p>
|
</button>
|
||||||
<p><kbd>p</kbd>: pause</p>
|
</div>
|
||||||
|
<div class="column message">
|
||||||
|
<p><kbd>s</kbd>: start record</p>
|
||||||
|
<p><kbd>n</kbd>: next step</p>
|
||||||
|
<p><kbd>p</kbd>: pause</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
39
src/modules/record/components/RecordResume.vue
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useTaskRecordMetadata } from '@/modules/record/hooks/useTaskRecordMetadata'
|
||||||
|
import type { Recordable } from '@/modules/record/interfaces/recordable'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
record: Recordable
|
||||||
|
totalEstimation: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { duration } = useTaskRecordMetadata(props.record)
|
||||||
|
|
||||||
|
const isSuperiorToEstimation = computed(() => {
|
||||||
|
if (!duration.value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return duration.value > props.totalEstimation
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="record-resume content" v-if="record.end">
|
||||||
|
<p
|
||||||
|
:class="{
|
||||||
|
'has-text-primary-dark': !isSuperiorToEstimation,
|
||||||
|
'has-text-warning-dark': isSuperiorToEstimation
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
The task took {{ duration }} minutes instead of
|
||||||
|
{{ totalEstimation }} minutes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.record-resume {
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -19,7 +19,9 @@ const step = computed(() => taskStore.getStep(props.taskId, props.stepId))
|
|||||||
const stepRecord = computed(() =>
|
const stepRecord = computed(() =>
|
||||||
recordStore.getStepRecord(props.taskId, props.stepId)
|
recordStore.getStepRecord(props.taskId, props.stepId)
|
||||||
)
|
)
|
||||||
const isCurrentStep = computed(() => recordStore.currentStepId === props.stepId)
|
const isCurrentStep = computed(
|
||||||
|
() => record.value?.currentStepId === props.stepId
|
||||||
|
)
|
||||||
const isInBreakTime = computed(() => !!record.value?.breakTime)
|
const isInBreakTime = computed(() => !!record.value?.breakTime)
|
||||||
|
|
||||||
const now = ref(toISODate(new Date()))
|
const now = ref(toISODate(new Date()))
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import EstimationTimeArrival from '@/components/EstimationTimeArrival.vue'
|
import EstimationTimeArrival from '@/components/EstimationTimeArrival.vue'
|
||||||
|
import RecordResume from '@/modules/record/components/RecordResume.vue'
|
||||||
import { useTaskStore } from '@/modules/task/stores/useTask.store'
|
import { useTaskStore } from '@/modules/task/stores/useTask.store'
|
||||||
import { formatLongDate } from '@/shared/format-date'
|
import { formatLongDate } from '@/shared/format-date'
|
||||||
import { useLoopyTitle } from '@/shared/useLoopyTitle'
|
import { useLoopyTitle } from '@/shared/useLoopyTitle'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useTaskRecordMetadata } from '../hooks/useTaskRecordMetadata'
|
|
||||||
import { useTaskRecordStore } from '../stores/useTaskRecordStore'
|
import { useTaskRecordStore } from '../stores/useTaskRecordStore'
|
||||||
import RecordControls from './RecordControls.vue'
|
import RecordControls from './RecordControls.vue'
|
||||||
import RecordProgress from './RecordProgress.vue'
|
import RecordProgress from './RecordProgress.vue'
|
||||||
@@ -19,23 +19,17 @@ const taskStore = useTaskStore()
|
|||||||
const recordStore = useTaskRecordStore()
|
const recordStore = useTaskRecordStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
recordStore.addRecord(props.taskId)
|
|
||||||
|
|
||||||
const task = computed(() => taskStore.getTask(props.taskId))
|
const task = computed(() => taskStore.getTask(props.taskId))
|
||||||
|
|
||||||
|
if (task.value) {
|
||||||
|
recordStore.syncTaskRecord(task.value)
|
||||||
|
}
|
||||||
|
recordStore.addRecord(props.taskId)
|
||||||
|
|
||||||
useLoopyTitle(task.value?.title ?? '')
|
useLoopyTitle(task.value?.title ?? '')
|
||||||
|
|
||||||
const record = computed(() => recordStore.getTaskRecord(props.taskId))
|
const record = computed(() => recordStore.getTaskRecord(props.taskId))
|
||||||
const recordNotes = computed(() => recordStore.getRecordNotes(props.taskId))
|
const recordNotes = computed(() => recordStore.getRecordNotes(props.taskId))
|
||||||
const { duration } = useTaskRecordMetadata(record)
|
|
||||||
|
|
||||||
const isSuperiorToEstimation = computed(() => {
|
|
||||||
if (!task.value || !record.value || !duration.value) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return duration.value > task.value.totalEstimation
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -72,17 +66,11 @@ const isSuperiorToEstimation = computed(() => {
|
|||||||
/>
|
/>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div v-show="record && record.end" class="content">
|
<record-resume
|
||||||
<p
|
v-if="record"
|
||||||
:class="{
|
:record="record"
|
||||||
'has-text-primary-dark': !isSuperiorToEstimation,
|
:total-estimation="task.totalEstimation"
|
||||||
'has-text-warning-dark': isSuperiorToEstimation
|
/>
|
||||||
}"
|
|
||||||
>
|
|
||||||
The task took {{ duration }} minutes instead of
|
|
||||||
{{ task.totalEstimation }} minutes.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="columns is-centered">
|
<div class="columns is-centered">
|
||||||
<div class="column is-half">
|
<div class="column is-half">
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ const mountTaskRecordPreview = (withRecord = false) => {
|
|||||||
'task-store': { tasks: [task] },
|
'task-store': { tasks: [task] },
|
||||||
'task-record-store': withRecord
|
'task-record-store': withRecord
|
||||||
? {
|
? {
|
||||||
currentStepId: null,
|
|
||||||
records: {
|
records: {
|
||||||
[task.id]: record
|
[task.id]: record
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
|
import type { Recordable } from '@/modules/record/interfaces/recordable'
|
||||||
import { formatDiffInMinutes } from '@/shared/format-date'
|
import { formatDiffInMinutes } from '@/shared/format-date'
|
||||||
import { computed, isRef, type Ref } from 'vue'
|
import { computed, isRef, type Ref } from 'vue'
|
||||||
import type { TaskRecord } from '../models/task-record'
|
|
||||||
|
|
||||||
export const useTaskRecordMetadata = (
|
export const useTaskRecordMetadata = (
|
||||||
record: TaskRecord | Ref<TaskRecord | null>
|
record: Recordable | Ref<Recordable | null>
|
||||||
) => {
|
) => {
|
||||||
const taskDurations = computed(() => {
|
const taskDurations = computed(() => {
|
||||||
const recordValue = isRef(record) ? record.value : record
|
const recordValue = isRef(record) ? record.value : record
|
||||||
|
|||||||
@@ -14,5 +14,6 @@ export const fixtureRecordable = (
|
|||||||
start: toISODate(faker.datatype.datetime())
|
start: toISODate(faker.datatype.datetime())
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
currentStepId: null,
|
||||||
end: partialRecordable?.end
|
end: partialRecordable?.end
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,4 +8,5 @@ export interface Recordable {
|
|||||||
stepRecords: Record<string, TimeRange>
|
stepRecords: Record<string, TimeRange>
|
||||||
notes: string
|
notes: string
|
||||||
breakTime?: TimeRange
|
breakTime?: TimeRange
|
||||||
|
currentStepId: string | null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ import { toISODate } from '@/shared/types/date'
|
|||||||
import { faker } from '@faker-js/faker'
|
import { faker } from '@faker-js/faker'
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import type { Recordable } from '../interfaces/recordable'
|
import type { Recordable } from '../interfaces/recordable'
|
||||||
|
import { fixtureRecordable } from '../interfaces/recordable.fixture'
|
||||||
import { fixtureTimeRange } from '../interfaces/time-range.fixture'
|
import { fixtureTimeRange } from '../interfaces/time-range.fixture'
|
||||||
import { TaskRecord } from './task-record'
|
import { TaskRecord } from './task-record'
|
||||||
|
|
||||||
describe('Task Record', () => {
|
describe('Task Record', () => {
|
||||||
it('creates a Record from a Recordable', () => {
|
it('creates a Record from a Recordable', () => {
|
||||||
const recordable: Recordable = {
|
const recordable: Recordable = fixtureRecordable({
|
||||||
taskId: faker.datatype.uuid(),
|
taskId: faker.datatype.uuid(),
|
||||||
notes: faker.lorem.paragraphs(),
|
notes: faker.lorem.paragraphs(),
|
||||||
start: toISODate(faker.date.past(1)),
|
start: toISODate(faker.date.past(1)),
|
||||||
@@ -16,7 +17,7 @@ describe('Task Record', () => {
|
|||||||
stepRecords: {
|
stepRecords: {
|
||||||
[faker.datatype.uuid()]: fixtureTimeRange()
|
[faker.datatype.uuid()]: fixtureTimeRange()
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
|
||||||
expect(TaskRecord.fromRecordable(recordable)).toEqual(recordable)
|
expect(TaskRecord.fromRecordable(recordable)).toEqual(recordable)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,17 +8,15 @@ export class TaskRecord implements Recordable {
|
|||||||
public stepRecords: Record<string, TimeRange> = {}
|
public stepRecords: Record<string, TimeRange> = {}
|
||||||
public notes = ''
|
public notes = ''
|
||||||
public breakTime?: TimeRange
|
public breakTime?: TimeRange
|
||||||
|
public currentStepId: string | null = null
|
||||||
|
|
||||||
public constructor(public readonly taskId: string) {}
|
public constructor(public readonly taskId: string) {}
|
||||||
|
|
||||||
public get hasStepRecords() {
|
|
||||||
return Object.values(this.stepRecords).length > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
public static fromRecordable(recordable: Recordable) {
|
public static fromRecordable(recordable: Recordable) {
|
||||||
const taskRecord = new TaskRecord(recordable.taskId)
|
const taskRecord = new TaskRecord(recordable.taskId)
|
||||||
|
|
||||||
taskRecord.stepRecords = recordable.stepRecords
|
taskRecord.stepRecords = recordable.stepRecords
|
||||||
|
taskRecord.currentStepId = recordable.currentStepId
|
||||||
taskRecord.start = recordable.start
|
taskRecord.start = recordable.start
|
||||||
taskRecord.end = recordable.end
|
taskRecord.end = recordable.end
|
||||||
taskRecord.breakTime = recordable.breakTime
|
taskRecord.breakTime = recordable.breakTime
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { Task } from '@/modules/task/models/task'
|
||||||
import { toISODate, type ISODate } from '@/shared/types/date'
|
import { toISODate, type ISODate } from '@/shared/types/date'
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import type { Recordable } from '../interfaces/recordable'
|
import type { Recordable } from '../interfaces/recordable'
|
||||||
@@ -6,17 +7,35 @@ import { TaskRecord } from '../models/task-record'
|
|||||||
import { addBreakTimeToStepRecords } from '../services/breaktime-service'
|
import { addBreakTimeToStepRecords } from '../services/breaktime-service'
|
||||||
|
|
||||||
export interface TaskRecordStoreState {
|
export interface TaskRecordStoreState {
|
||||||
currentStepId: string | null
|
|
||||||
records: { [recordId: string]: Recordable }
|
records: { [recordId: string]: Recordable }
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useTaskRecordStore = defineStore('task-record-store', {
|
export const useTaskRecordStore = defineStore('task-record-store', {
|
||||||
persist: true,
|
persist: true,
|
||||||
state: (): TaskRecordStoreState => ({
|
state: (): TaskRecordStoreState => ({
|
||||||
currentStepId: null,
|
|
||||||
records: {}
|
records: {}
|
||||||
}),
|
}),
|
||||||
actions: {
|
actions: {
|
||||||
|
syncTaskRecord(task: Task) {
|
||||||
|
if (!(task.id in this.records)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = this.records[task.id]
|
||||||
|
|
||||||
|
const taskRecordStepIds = Object.keys(record)
|
||||||
|
const taskStepIds = new Set(task.steps.map((step) => step.id))
|
||||||
|
|
||||||
|
const hasSameSteps =
|
||||||
|
taskRecordStepIds.length === taskStepIds.size &&
|
||||||
|
taskRecordStepIds.every((taskRecordStepId) =>
|
||||||
|
taskStepIds.has(taskRecordStepId)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!hasSameSteps) {
|
||||||
|
this.records[task.id] = new TaskRecord(task.id)
|
||||||
|
}
|
||||||
|
},
|
||||||
addRecord(taskId: string) {
|
addRecord(taskId: string) {
|
||||||
if (taskId in this.records) {
|
if (taskId in this.records) {
|
||||||
return
|
return
|
||||||
@@ -57,10 +76,10 @@ export const useTaskRecordStore = defineStore('task-record-store', {
|
|||||||
[params.stepId]: {
|
[params.stepId]: {
|
||||||
start: params.start
|
start: params.start
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
currentStepId: params.stepId
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
currentStepId: params.stepId
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
endStepRecord(params: { taskId: string; stepId: string; end: ISODate }) {
|
endStepRecord(params: { taskId: string; stepId: string; end: ISODate }) {
|
||||||
@@ -101,30 +120,32 @@ export const useTaskRecordStore = defineStore('task-record-store', {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.records[taskId].end = toISODate(new Date())
|
this.records[taskId].end = toISODate(new Date())
|
||||||
this.currentStepId = null
|
this.records[taskId].currentStepId = null
|
||||||
},
|
},
|
||||||
updateRecordNotes(taskId: string, notes: string) {
|
updateRecordNotes(taskId: string, notes: string) {
|
||||||
const record = this.records[taskId]
|
const record = this.records[taskId]
|
||||||
|
|
||||||
if (record) {
|
if (!record) {
|
||||||
this.$patch({
|
return
|
||||||
records: {
|
|
||||||
...this.records,
|
|
||||||
[taskId]: {
|
|
||||||
...record,
|
|
||||||
notes
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.$patch({
|
||||||
|
records: {
|
||||||
|
...this.records,
|
||||||
|
[taskId]: {
|
||||||
|
...record,
|
||||||
|
notes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
},
|
},
|
||||||
reset(taskId: string) {
|
reset(taskId: string) {
|
||||||
this.currentStepId = null
|
|
||||||
if (!this.records[taskId]) {
|
if (!this.records[taskId]) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.records[taskId].stepRecords = {}
|
this.records[taskId].stepRecords = {}
|
||||||
this.records[taskId].end = undefined
|
this.records[taskId].end = undefined
|
||||||
|
this.records[taskId].currentStepId = null
|
||||||
},
|
},
|
||||||
pause(taskId: string) {
|
pause(taskId: string) {
|
||||||
if (this.records[taskId]?.breakTime) {
|
if (this.records[taskId]?.breakTime) {
|
||||||
|
|||||||
@@ -1,79 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import EstimationTimeArrival from '@/components/EstimationTimeArrival.vue'
|
|
||||||
import { createUuid } from '@/shared/create-uuid'
|
import { createUuid } from '@/shared/create-uuid'
|
||||||
import { computed, ref } from 'vue'
|
import TaskForm from './TaskForm.vue'
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import type { Stepable } from '../interfaces/stepable'
|
|
||||||
import { Task } from '../models/task'
|
|
||||||
import { useTaskStore } from '../stores/useTask.store'
|
|
||||||
import StepInput from './StepInput.vue'
|
|
||||||
|
|
||||||
const store = useTaskStore()
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const id = createUuid()
|
const id = createUuid()
|
||||||
|
|
||||||
const title = ref('')
|
|
||||||
const link = ref('')
|
|
||||||
|
|
||||||
const steps = ref<Stepable[]>([])
|
|
||||||
|
|
||||||
const totalEstimation = computed(() =>
|
|
||||||
steps.value.map((step) => step.estimation).reduce((a, b) => a + b, 0)
|
|
||||||
)
|
|
||||||
|
|
||||||
const saveTask = () => {
|
|
||||||
const task = new Task(id, title.value)
|
|
||||||
if (link.value) {
|
|
||||||
task.link = link.value
|
|
||||||
}
|
|
||||||
task.addSteps(...steps.value)
|
|
||||||
|
|
||||||
if (Task.validate(task)) {
|
|
||||||
store.saveTask(task)
|
|
||||||
router.push({
|
|
||||||
name: 'task-view',
|
|
||||||
params: {
|
|
||||||
id
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="columns is-centered">
|
<task-form :id="id" />
|
||||||
<div class="column is-half">
|
|
||||||
<h1 class="title">Create a task</h1>
|
|
||||||
<h2 class="subtitle">
|
|
||||||
<estimation-time-arrival :estimation="totalEstimation" />
|
|
||||||
</h2>
|
|
||||||
<form @submit.prevent="saveTask">
|
|
||||||
<div class="field">
|
|
||||||
<label class="label" for="title">Title</label>
|
|
||||||
<div class="control">
|
|
||||||
<input class="input" type="text" id="title" v-model="title" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label class="label" for="link">User story link</label>
|
|
||||||
<div class="control">
|
|
||||||
<input class="input" type="text" id="link" v-model="link" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<step-input v-model="steps" />
|
|
||||||
<button class="button is-primary is-fullwidth" type="submit">
|
|
||||||
save
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
form {
|
|
||||||
padding: 1rem 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
85
src/modules/task/components/TaskForm.vue
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import EstimationTimeArrival from '@/components/EstimationTimeArrival.vue'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import type { Stepable } from '../interfaces/stepable'
|
||||||
|
import type { Taskable } from '../interfaces/taskable'
|
||||||
|
import { Task } from '../models/task'
|
||||||
|
import { useTaskStore } from '../stores/useTask.store'
|
||||||
|
import StepInput from './StepInput.vue'
|
||||||
|
|
||||||
|
const store = useTaskStore()
|
||||||
|
const router = useRouter()
|
||||||
|
const props = defineProps<{ id: string; initialTask?: Taskable }>()
|
||||||
|
const id = computed(() => props.id)
|
||||||
|
|
||||||
|
const steps = ref<Stepable[]>(props.initialTask?.steps ?? [])
|
||||||
|
|
||||||
|
const title = ref(props.initialTask?.title ?? '')
|
||||||
|
const link = ref(props.initialTask?.link ?? '')
|
||||||
|
|
||||||
|
const totalEstimation = computed(() =>
|
||||||
|
steps.value.map((step) => step.estimation).reduce((a, b) => a + b, 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
const saveTask = () => {
|
||||||
|
const task = new Task(id.value, title.value)
|
||||||
|
if (link.value) {
|
||||||
|
task.link = link.value
|
||||||
|
}
|
||||||
|
task.addSteps(...steps.value)
|
||||||
|
|
||||||
|
if (Task.validate(task)) {
|
||||||
|
store.saveTask(task)
|
||||||
|
router.push({
|
||||||
|
name: 'task-view',
|
||||||
|
params: {
|
||||||
|
id: id.value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="task-form columns is-centered">
|
||||||
|
<div class="column is-half">
|
||||||
|
<h1 class="title">Create a task</h1>
|
||||||
|
<h2 class="subtitle">
|
||||||
|
<estimation-time-arrival :estimation="totalEstimation" />
|
||||||
|
</h2>
|
||||||
|
<form @submit.prevent="saveTask">
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="title">Title</label>
|
||||||
|
<div class="control">
|
||||||
|
<input class="input" type="text" id="title" v-model="title" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="link">User story link</label>
|
||||||
|
<div class="control">
|
||||||
|
<input class="input" type="text" id="link" v-model="link" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<step-input v-model="steps" />
|
||||||
|
<div class="columns is-centered">
|
||||||
|
<div class="column is-one-third">
|
||||||
|
<button class="button is-primary is-fullwidth" type="submit">
|
||||||
|
save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.task-form {
|
||||||
|
form {
|
||||||
|
padding: 1rem 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
20
src/modules/task/components/TaskNotFound.vue
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<template>
|
||||||
|
<div class="no-task-found">
|
||||||
|
<p>Task not found.</p>
|
||||||
|
<router-link :to="{ name: 'home' }" class="button">
|
||||||
|
<img src="/icons/left.svg" alt="left arrow" />
|
||||||
|
go to homepage</router-link
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.no-task-found {
|
||||||
|
margin: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -14,6 +14,7 @@ export const useTaskStore = defineStore('task-store', {
|
|||||||
}),
|
}),
|
||||||
actions: {
|
actions: {
|
||||||
saveTask(task: Taskable) {
|
saveTask(task: Taskable) {
|
||||||
|
this.remove(task.id)
|
||||||
this.tasks.push(task)
|
this.tasks.push(task)
|
||||||
},
|
},
|
||||||
reset() {
|
reset() {
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ export const router = createRouter({
|
|||||||
props: true,
|
props: true,
|
||||||
component: () => import('../views/task/TaskView.vue')
|
component: () => import('../views/task/TaskView.vue')
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/task/:id/edit',
|
||||||
|
name: 'edit-task',
|
||||||
|
props: true,
|
||||||
|
component: () => import('../views/task/EditTask.vue')
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/task/:taskId/record',
|
path: '/task/:taskId/record',
|
||||||
name: 'record-view',
|
name: 'record-view',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ISODate } from './types/date'
|
import type { ISODate } from './types/date'
|
||||||
|
|
||||||
const isTimeSpeedUp = () => false // process.env.NODE_ENV === 'development'
|
const isTimeSpeedUp = () => process.env.NODE_ENV === 'development'
|
||||||
|
|
||||||
export const formatDate = (date: Date | string) =>
|
export const formatDate = (date: Date | string) =>
|
||||||
new Date(date).toLocaleString()
|
new Date(date).toLocaleString()
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { fixtureRecordable } from '@/modules/record/interfaces/recordable.fixture'
|
||||||
import type { TaskRecordStoreState } from '@/modules/record/stores/useTaskRecordStore'
|
import type { TaskRecordStoreState } from '@/modules/record/stores/useTaskRecordStore'
|
||||||
import { fixtureTask } from '@/modules/task/models/task.fixture'
|
import { fixtureTask } from '@/modules/task/models/task.fixture'
|
||||||
import type { TaskStoreState } from '@/modules/task/stores/useTask.store'
|
import type { TaskStoreState } from '@/modules/task/stores/useTask.store'
|
||||||
@@ -18,20 +19,19 @@ const [firstTask, secondTask] = tasks
|
|||||||
const initialState: InitialState = {
|
const initialState: InitialState = {
|
||||||
'task-store': { tasks },
|
'task-store': { tasks },
|
||||||
'task-record-store': {
|
'task-record-store': {
|
||||||
currentStepId: null,
|
|
||||||
records: {
|
records: {
|
||||||
[firstTask.id]: {
|
[firstTask.id]: fixtureRecordable({
|
||||||
taskId: firstTask.id,
|
taskId: firstTask.id,
|
||||||
stepRecords: {},
|
stepRecords: {},
|
||||||
start: toISODate(new Date()),
|
start: toISODate(new Date()),
|
||||||
notes: ''
|
notes: ''
|
||||||
},
|
}),
|
||||||
[secondTask.id]: {
|
[secondTask.id]: fixtureRecordable({
|
||||||
taskId: secondTask.id,
|
taskId: secondTask.id,
|
||||||
stepRecords: {},
|
stepRecords: {},
|
||||||
start: toISODate(new Date()),
|
start: toISODate(new Date()),
|
||||||
notes: ''
|
notes: ''
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
31
src/views/task/EditTask.vue
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import TaskForm from '@/modules/task/components/TaskForm.vue'
|
||||||
|
import TaskNotFound from '@/modules/task/components/TaskNotFound.vue'
|
||||||
|
import { useTaskStore } from '@/modules/task/stores/useTask.store'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{ id: string }>()
|
||||||
|
const taskStore = useTaskStore()
|
||||||
|
|
||||||
|
const isMessageDisplayed = ref(true)
|
||||||
|
|
||||||
|
const task = taskStore.getTask(props.id)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="edit-task">
|
||||||
|
<TaskForm v-if="task" :id="task.id" :initial-task="task" />
|
||||||
|
<task-not-found v-else />
|
||||||
|
<article v-if="isMessageDisplayed" class="message is-info">
|
||||||
|
<div class="message-header">
|
||||||
|
<p>Info</p>
|
||||||
|
<button
|
||||||
|
class="delete"
|
||||||
|
aria-label="delete"
|
||||||
|
@click="isMessageDisplayed = false"
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
<div class="message-body">When editing a task, record will be reset.</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import EstimationTimeArrival from '@/components/EstimationTimeArrival.vue'
|
import EstimationTimeArrival from '@/components/EstimationTimeArrival.vue'
|
||||||
import TaskRecordPreview from '@/modules/record/components/TaskRecordPreview.vue'
|
import TaskRecordPreview from '@/modules/record/components/TaskRecordPreview.vue'
|
||||||
|
import TaskNotFound from '@/modules/task/components/TaskNotFound.vue'
|
||||||
import { useTaskStore } from '@/modules/task/stores/useTask.store'
|
import { useTaskStore } from '@/modules/task/stores/useTask.store'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
@@ -26,9 +27,22 @@ const deleteTask = () => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="task-view" v-if="task">
|
<div class="task-view" v-if="task">
|
||||||
<button class="delete-task button is-light is-danger" @click="deleteTask">
|
<div class="buttons actions">
|
||||||
<img src="/icons/trash.svg" alt="delete task" />
|
<router-link
|
||||||
</button>
|
:to="{
|
||||||
|
name: 'edit-task',
|
||||||
|
params: {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}"
|
||||||
|
class="button"
|
||||||
|
>
|
||||||
|
<img src="/icons/edit.svg" alt="edit task" />
|
||||||
|
</router-link>
|
||||||
|
<button class="delete-task button is-light is-danger" @click="deleteTask">
|
||||||
|
<img src="/icons/trash.svg" alt="delete task" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<h1 class="title">{{ task.title }}</h1>
|
<h1 class="title">{{ task.title }}</h1>
|
||||||
<h2 class="subtitle">
|
<h2 class="subtitle">
|
||||||
<estimation-time-arrival :estimation="task.totalEstimation" />
|
<estimation-time-arrival :estimation="task.totalEstimation" />
|
||||||
@@ -53,7 +67,7 @@ const deleteTask = () => {
|
|||||||
</div>
|
</div>
|
||||||
<task-record-preview :task-id="id" />
|
<task-record-preview :task-id="id" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else>Task not found</div>
|
<task-not-found v-else />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -65,7 +79,7 @@ const deleteTask = () => {
|
|||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.delete-task {
|
.actions {
|
||||||
float: right;
|
float: right;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,11 +1,95 @@
|
|||||||
import { fileURLToPath, URL } from 'node:url'
|
|
||||||
|
|
||||||
import { defineConfig } from 'vite'
|
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import { VitePWA } from 'vite-plugin-pwa'
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue()],
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
VitePWA({
|
||||||
|
manifest: {
|
||||||
|
name: 'Loopycode',
|
||||||
|
short_name: 'Loopycode',
|
||||||
|
description: 'Feedback loop when coding',
|
||||||
|
theme_color: '#192a56',
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: 'favicon/apple-touch-icon-60x60.png',
|
||||||
|
sizes: '60x60',
|
||||||
|
type: 'image/png'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: 'favicon/apple-touch-icon-76x76.png',
|
||||||
|
sizes: '76x76',
|
||||||
|
type: 'image/png'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: 'favicon/apple-touch-icon-120x120.png',
|
||||||
|
sizes: '120x120',
|
||||||
|
type: 'image/png'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: 'favicon/apple-touch-icon-152x152.png',
|
||||||
|
sizes: '152x152',
|
||||||
|
type: 'image/png'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: 'favicon/apple-touch-icon-180x180.png',
|
||||||
|
sizes: '180x180',
|
||||||
|
type: 'image/png'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: 'favicon/apple-touch-icon.png',
|
||||||
|
sizes: '180x180',
|
||||||
|
type: 'image/png'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: 'favicon/favicon-16x16.png',
|
||||||
|
sizes: '16x16',
|
||||||
|
type: 'image/png'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: 'favicon/favicon-32x32.png',
|
||||||
|
sizes: '32x32',
|
||||||
|
type: 'image/png'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: 'favicon/msapplication-icon-144x144.png',
|
||||||
|
sizes: '144x144',
|
||||||
|
type: 'image/png'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: 'favicon/mstile-150x150.png',
|
||||||
|
sizes: '150x150',
|
||||||
|
type: 'image/png'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: 'favicon/android-chrome-192x192.png',
|
||||||
|
sizes: '192x192',
|
||||||
|
type: 'image/png'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: 'favicon/android-chrome-512x512.png',
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: 'favicon/android-chrome-maskable-192x192.png',
|
||||||
|
sizes: '192x192',
|
||||||
|
type: 'image/png',
|
||||||
|
purpose: 'maskable'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: 'favicon/android-chrome-maskable-512x512.png',
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png',
|
||||||
|
purpose: 'maskable'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||||
|
|||||||