Feat/add bulma (#1)
* remove fixture for task and steps * install bulma * convert to scss files * reset and implement first style classes * design task form * design task view * design step record * 💄 (home) new task and reset styling * fix step title height * add a record progress * ♻️ (record) extract record controls * ♿️ (record) add text for progress * 💄 (step record) fix blob size * ♻️ (record) no more getters who do an action too
This commit is contained in:
@@ -6,7 +6,7 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "run-p type-check build-only",
|
"build": "run-p type-check build-only",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "pnpm lint && pnpm type-check && pnpm test:unit",
|
"test": "pnpm lint && pnpm type-check && pnpm test:unit --ui",
|
||||||
"test:unit": "vitest",
|
"test:unit": "vitest",
|
||||||
"build-only": "vite build",
|
"build-only": "vite build",
|
||||||
"type-check": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false",
|
"type-check": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false",
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vueuse/core": "^9.13.0",
|
"@vueuse/core": "^9.13.0",
|
||||||
|
"bulma": "^0.9.4",
|
||||||
"nanoid": "^4.0.2",
|
"nanoid": "^4.0.2",
|
||||||
"pinia": "^2.0.34",
|
"pinia": "^2.0.34",
|
||||||
"pinia-plugin-persistedstate": "^3.1.0",
|
"pinia-plugin-persistedstate": "^3.1.0",
|
||||||
@@ -29,6 +30,8 @@
|
|||||||
"@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/ui": "^0.30.1",
|
||||||
"@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.2",
|
||||||
"@vue/test-utils": "^2.3.2",
|
"@vue/test-utils": "^2.3.2",
|
||||||
@@ -42,6 +45,7 @@
|
|||||||
"typescript": "~4.8.4",
|
"typescript": "~4.8.4",
|
||||||
"vite": "^4.2.1",
|
"vite": "^4.2.1",
|
||||||
"vitest": "^0.29.8",
|
"vitest": "^0.29.8",
|
||||||
"vue-tsc": "^1.2.0"
|
"vue-tsc": "^1.2.0",
|
||||||
|
"webdriverio": "^8.8.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
1564
pnpm-lock.yaml
generated
1564
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
72
src/App.vue
72
src/App.vue
@@ -4,76 +4,12 @@ import { RouterLink, RouterView } from 'vue-router'
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<header>
|
<header>
|
||||||
<div class="wrapper">
|
<nav>
|
||||||
<nav>
|
<router-link class="title is-3" to="/">Loopycode</router-link>
|
||||||
<router-link to="/">Home</router-link>
|
</nav>
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<RouterView />
|
<RouterView />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped></style>
|
||||||
header {
|
|
||||||
line-height: 1;
|
|
||||||
max-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
display: block;
|
|
||||||
margin: 0 auto 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav {
|
|
||||||
width: 100%;
|
|
||||||
font-size: 12px;
|
|
||||||
text-align: center;
|
|
||||||
margin-top: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav a.router-link-exact-active {
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
nav a.router-link-exact-active:hover {
|
|
||||||
background-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav a {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 0 1rem;
|
|
||||||
border-left: 1px solid var(--color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
nav a:first-of-type {
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
header {
|
|
||||||
display: flex;
|
|
||||||
place-items: center;
|
|
||||||
padding-right: calc(var(--section-gap) / 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
margin: 0 2rem 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
header .wrapper {
|
|
||||||
display: flex;
|
|
||||||
place-items: flex-start;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav {
|
|
||||||
text-align: left;
|
|
||||||
margin-left: -1rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
|
|
||||||
padding: 1rem 0;
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
@import url('https://fonts.googleapis.com/css2?family=Oxygen+Mono&display=swap');
|
|
||||||
|
|
||||||
/* color palette from <https://github.com/vuejs/theme> */
|
|
||||||
:root {
|
|
||||||
--vt-c-white: #ffffff;
|
|
||||||
--vt-c-white-soft: #f8f8f8;
|
|
||||||
--vt-c-white-mute: #f2f2f2;
|
|
||||||
|
|
||||||
--vt-c-black: #181818;
|
|
||||||
--vt-c-black-soft: #222222;
|
|
||||||
--vt-c-black-mute: #282828;
|
|
||||||
|
|
||||||
--vt-c-indigo: #2c3e50;
|
|
||||||
|
|
||||||
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
|
|
||||||
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
|
|
||||||
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
|
|
||||||
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
|
|
||||||
|
|
||||||
--vt-c-text-light-1: var(--vt-c-indigo);
|
|
||||||
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
|
|
||||||
--vt-c-text-dark-1: var(--vt-c-white);
|
|
||||||
--vt-c-text-dark-2: rgba(251, 251, 251, 0.798);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* semantic color variables for this project */
|
|
||||||
:root {
|
|
||||||
--color-background: var(--vt-c-white);
|
|
||||||
--color-background-soft: var(--vt-c-white-soft);
|
|
||||||
--color-background-mute: var(--vt-c-white-mute);
|
|
||||||
|
|
||||||
--color-border: var(--vt-c-divider-light-2);
|
|
||||||
--color-border-hover: var(--vt-c-divider-light-1);
|
|
||||||
|
|
||||||
--color-heading: var(--vt-c-text-light-1);
|
|
||||||
--color-text: var(--vt-c-text-light-1);
|
|
||||||
|
|
||||||
--section-gap: 160px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
|
||||||
--color-background: var(--vt-c-black);
|
|
||||||
--color-background-soft: var(--vt-c-black-soft);
|
|
||||||
--color-background-mute: var(--vt-c-black-mute);
|
|
||||||
|
|
||||||
--color-border: var(--vt-c-divider-dark-2);
|
|
||||||
--color-border-hover: var(--vt-c-divider-dark-1);
|
|
||||||
|
|
||||||
--color-heading: var(--vt-c-text-dark-1);
|
|
||||||
--color-text: var(--vt-c-text-dark-2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
*,
|
|
||||||
*::before,
|
|
||||||
*::after {
|
|
||||||
box-sizing: border-box;
|
|
||||||
margin: 0;
|
|
||||||
position: relative;
|
|
||||||
font-weight: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
color: var(--color-text);
|
|
||||||
background: var(--color-background);
|
|
||||||
transition: color 0.5s, background-color 0.5s;
|
|
||||||
line-height: 1.6;
|
|
||||||
font-family: 'Oxygen Mono', monospace;
|
|
||||||
font-size: 15px;
|
|
||||||
text-rendering: optimizeLegibility;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
font-family: 'Oxygen Mono', monospace;
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
@import './base.css';
|
|
||||||
|
|
||||||
#app {
|
|
||||||
max-width: 1280px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 0 1rem;
|
|
||||||
|
|
||||||
font-weight: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
a,
|
|
||||||
.green {
|
|
||||||
text-decoration: none;
|
|
||||||
color: hsla(160, 100%, 37%, 1);
|
|
||||||
transition: 0.4s;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (hover: hover) {
|
|
||||||
a:hover {
|
|
||||||
background-color: hsla(160, 100%, 37%, 0.2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
body {
|
|
||||||
display: flex;
|
|
||||||
place-items: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
26
src/assets/main.scss
Normal file
26
src/assets/main.scss
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
@charset "utf-8";
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Oxygen+Mono&display=swap');
|
||||||
|
|
||||||
|
$family-monospace: 'Oxygen Mono',
|
||||||
|
monospace;
|
||||||
|
|
||||||
|
$family-primary: $family-monospace;
|
||||||
|
|
||||||
|
@import '../../node_modules/bulma/bulma.sass';
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
transition: color 0.5s, background-color 0.5s;
|
||||||
|
font-size: 16px;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
margin: 0 1rem;
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import { createApp } from 'vue'
|
|||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import { router } from './router'
|
import { router } from './router'
|
||||||
|
|
||||||
import './assets/main.css'
|
import './assets/main.scss'
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
|
||||||
|
|||||||
94
src/modules/record/components/RecordControls.vue
Normal file
94
src/modules/record/components/RecordControls.vue
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useTaskStore } from '@/modules/task/stores/useTask.store'
|
||||||
|
import { toISODate } from '@/shared/types/date'
|
||||||
|
import { useMagicKeys, whenever } from '@vueuse/core'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useTaskRecordStore } from '../stores/useTaskRecordStore'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
taskId: string
|
||||||
|
recordId: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const taskStore = useTaskStore()
|
||||||
|
const recordStore = useTaskRecordStore()
|
||||||
|
|
||||||
|
const task = computed(() => taskStore.getTask(props.taskId))
|
||||||
|
const record = computed(() => recordStore.getTaskRecord(props.recordId))
|
||||||
|
|
||||||
|
const getNextStepId = () => {
|
||||||
|
if (!task.value) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!recordStore.currentStepId) {
|
||||||
|
const [firstStep] = task.value.steps
|
||||||
|
return firstStep.id
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentStepIndex = task.value.steps.findIndex(
|
||||||
|
(step) => step.id === recordStore.currentStepId
|
||||||
|
)
|
||||||
|
|
||||||
|
const canHaveNextIndex =
|
||||||
|
currentStepIndex >= 0 && currentStepIndex < task.value.steps.length - 1
|
||||||
|
|
||||||
|
if (canHaveNextIndex) {
|
||||||
|
return task.value.steps[currentStepIndex + 1].id
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const canStart = computed(() => !recordStore.currentStepId)
|
||||||
|
|
||||||
|
const startRecording = () => {
|
||||||
|
if (!canStart.value || !task.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
recordStore.startStepRecord({
|
||||||
|
recordId: props.recordId,
|
||||||
|
stepId: task.value.steps[0].id,
|
||||||
|
start: toISODate(new Date())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextStep = () => {
|
||||||
|
if (!task.value || !recordStore.currentStepId || !record.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
recordStore.nextStepRecord({
|
||||||
|
recordId: record.value.id,
|
||||||
|
currentStepId: recordStore.currentStepId,
|
||||||
|
nextStepId: getNextStepId(),
|
||||||
|
tick: toISODate(new Date())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const { n, s } = useMagicKeys()
|
||||||
|
|
||||||
|
whenever(n, () => {
|
||||||
|
nextStep()
|
||||||
|
})
|
||||||
|
|
||||||
|
whenever(s, () => {
|
||||||
|
startRecording()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="record-controls buttons">
|
||||||
|
<template v-if="!record || !record.end">
|
||||||
|
<button v-if="canStart" @click="startRecording" class="button is-primary">
|
||||||
|
start
|
||||||
|
</button>
|
||||||
|
<button class="button" v-else @click="nextStep">next</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<button class="button is-warning" @click="recordStore.reset(recordId)">
|
||||||
|
reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
33
src/modules/record/components/RecordProgress.vue
Normal file
33
src/modules/record/components/RecordProgress.vue
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useTaskStore } from '@/modules/task/stores/useTask.store'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useTaskRecordStore } from '../stores/useTaskRecordStore'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
taskId: string
|
||||||
|
recordId: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const taskStore = useTaskStore()
|
||||||
|
const taskRecordStore = useTaskRecordStore()
|
||||||
|
|
||||||
|
const task = computed(() => taskStore.getTask(props.taskId))
|
||||||
|
const record = computed(() => taskRecordStore.getRecord(props.recordId))
|
||||||
|
|
||||||
|
const numberOfFinishedSteps = computed(
|
||||||
|
() =>
|
||||||
|
Object.values(record.value?.stepRecords ?? {}).filter((step) => !!step.end)
|
||||||
|
.length
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<progress
|
||||||
|
v-if="task && record"
|
||||||
|
class="progress is-primary"
|
||||||
|
:value="numberOfFinishedSteps"
|
||||||
|
:max="task.steps.length ?? 0"
|
||||||
|
>
|
||||||
|
{{ numberOfFinishedSteps }}/{{ task.steps.length }}
|
||||||
|
</progress>
|
||||||
|
</template>
|
||||||
@@ -59,17 +59,21 @@ const isSuperiorToEstimation = computed(() => {
|
|||||||
<span v-else-if="isSuperiorToEstimation"> ⚠️ </span>
|
<span v-else-if="isSuperiorToEstimation"> ⚠️ </span>
|
||||||
<span v-else>⌛</span>
|
<span v-else>⌛</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="title">
|
<td>
|
||||||
<div v-if="isCurrentStep" class="blob green"></div>
|
<div class="step-title">
|
||||||
{{ step.title }}
|
<div v-if="isCurrentStep" class="blob green"></div>
|
||||||
|
{{ step.title }}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="estimation">{{ step.estimation }} minutes</td>
|
<td class="estimation minutes">{{ step.estimation }} min</td>
|
||||||
<td v-if="stepRecord">{{ duration }} minutes</td>
|
<td class="minutes" v-if="stepRecord">{{ duration }} min</td>
|
||||||
<td v-else></td>
|
<td v-else></td>
|
||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
|
$blob-size: 15px;
|
||||||
|
|
||||||
.step-record {
|
.step-record {
|
||||||
.status {
|
.status {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -85,19 +89,23 @@ const isSuperiorToEstimation = computed(() => {
|
|||||||
|
|
||||||
.blob {
|
.blob {
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
height: 10px;
|
min-height: $blob-size;
|
||||||
width: 10px;
|
min-width: $blob-size;
|
||||||
background: rgba(51, 217, 178, 1);
|
background: rgba(51, 217, 178, 1);
|
||||||
box-shadow: 0 0 0 0 rgba(51, 217, 178, 1);
|
box-shadow: 0 0 0 0 rgba(51, 217, 178, 1);
|
||||||
animation: pulse 2s infinite;
|
animation: pulse 2s infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.step-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding-right: 1rem;
|
padding-right: 1rem;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.minutes {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
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 { toISODate } from '@/shared/types/date'
|
|
||||||
import { useMagicKeys, whenever } from '@vueuse/core'
|
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useTaskRecordMetadata } from '../hooks/useTaskRecordMetadata'
|
import { useTaskRecordMetadata } from '../hooks/useTaskRecordMetadata'
|
||||||
import { useTaskRecordStore } from '../stores/useTaskRecordStore'
|
import { useTaskRecordStore } from '../stores/useTaskRecordStore'
|
||||||
|
import RecordControls from './RecordControls.vue'
|
||||||
|
import RecordProgress from './RecordProgress.vue'
|
||||||
import StepRecord from './StepRecord.vue'
|
import StepRecord from './StepRecord.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -16,69 +16,14 @@ const props = defineProps<{
|
|||||||
const taskStore = useTaskStore()
|
const taskStore = useTaskStore()
|
||||||
const recordStore = useTaskRecordStore()
|
const recordStore = useTaskRecordStore()
|
||||||
|
|
||||||
|
recordStore.addRecord(props.taskId, props.recordId)
|
||||||
|
|
||||||
const task = computed(() => taskStore.getTask(props.taskId))
|
const task = computed(() => taskStore.getTask(props.taskId))
|
||||||
|
|
||||||
const record = computed(() =>
|
const record = computed(() => recordStore.getTaskRecord(props.recordId))
|
||||||
recordStore.createAndRetrieveTaskRecord(props.taskId, props.recordId)
|
|
||||||
)
|
|
||||||
const recordNotes = computed(() => recordStore.getRecordNotes(props.recordId))
|
const recordNotes = computed(() => recordStore.getRecordNotes(props.recordId))
|
||||||
const { duration } = useTaskRecordMetadata(record)
|
const { duration } = useTaskRecordMetadata(record)
|
||||||
|
|
||||||
const getNextStepId = () => {
|
|
||||||
if (!task.value) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!recordStore.currentStepId) {
|
|
||||||
const [firstStep] = task.value.steps
|
|
||||||
return firstStep.id
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentStepIndex = task.value.steps.findIndex(
|
|
||||||
(step) => step.id === recordStore.currentStepId
|
|
||||||
)
|
|
||||||
|
|
||||||
const canHaveNextIndex =
|
|
||||||
currentStepIndex >= 0 && currentStepIndex < task.value.steps.length - 1
|
|
||||||
|
|
||||||
if (canHaveNextIndex) {
|
|
||||||
return task.value.steps[currentStepIndex + 1].id
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const startRecording = () => {
|
|
||||||
if (!task.value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
recordStore.startStepRecord({
|
|
||||||
recordId: props.recordId,
|
|
||||||
stepId: task.value.steps[0].id,
|
|
||||||
start: toISODate(new Date())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextStep = () => {
|
|
||||||
if (!task.value || !recordStore.currentStepId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
recordStore.nextStepRecord({
|
|
||||||
recordId: record.value.id,
|
|
||||||
currentStepId: recordStore.currentStepId,
|
|
||||||
nextStepId: getNextStepId(),
|
|
||||||
tick: toISODate(new Date())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const { n } = useMagicKeys()
|
|
||||||
|
|
||||||
whenever(n, () => {
|
|
||||||
nextStep()
|
|
||||||
})
|
|
||||||
|
|
||||||
const isSuperiorToEstimation = computed(() => {
|
const isSuperiorToEstimation = computed(() => {
|
||||||
if (!task.value || !record.value || !duration.value) {
|
if (!task.value || !record.value || !duration.value) {
|
||||||
return false
|
return false
|
||||||
@@ -90,25 +35,18 @@ const isSuperiorToEstimation = computed(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main class="task-record" v-if="task">
|
<main class="task-record" v-if="task">
|
||||||
<h1>
|
<record-progress :task-id="taskId" :record-id="recordId" />
|
||||||
Task:
|
<h1 class="title">
|
||||||
<router-link :to="{ name: 'task-view', params: { id: task.id } }">
|
<router-link
|
||||||
|
:to="{ name: 'task-view', params: { id: task.id } }"
|
||||||
|
class="button is-link is-light"
|
||||||
|
>
|
||||||
{{ task.title }}
|
{{ task.title }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</h1>
|
</h1>
|
||||||
<h2>{{ formatLongDate(record.start) }}</h2>
|
<h2 class="subtitle" v-if="record">{{ formatLongDate(record.start) }}</h2>
|
||||||
<template v-if="!record.end">
|
<record-controls :task-id="taskId" :record-id="recordId" />
|
||||||
<button
|
<table class="table is-striped is-narrow is-hoverable is-fullwidth">
|
||||||
v-if="!recordStore.currentStepId && !record.hasStepRecords"
|
|
||||||
@click="startRecording"
|
|
||||||
>
|
|
||||||
start
|
|
||||||
</button>
|
|
||||||
<button v-else @click="nextStep">next</button>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<button @click="recordStore.$reset">reset</button>
|
|
||||||
<table>
|
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>#</th>
|
<th>#</th>
|
||||||
@@ -119,44 +57,42 @@ const isSuperiorToEstimation = computed(() => {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<StepRecord
|
<step-record
|
||||||
v-for="(step, key) in task.steps"
|
v-for="(step, key) in task.steps"
|
||||||
:task-id="taskId"
|
:task-id="taskId"
|
||||||
:record-id="recordId"
|
:record-id="recordId"
|
||||||
:key="step.id"
|
:key="step.id"
|
||||||
:step-id="step.id"
|
:step-id="step.id"
|
||||||
:step-number="key"
|
:step-number="key + 1"
|
||||||
/>
|
/>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div v-if="record.end">
|
<div v-show="record && record.end" class="content">
|
||||||
<hr />
|
<p
|
||||||
The task took {{ duration }} minutes instead of
|
:class="{
|
||||||
{{ task.totalEstimation }} minutes.
|
'has-text-primary-dark': !isSuperiorToEstimation,
|
||||||
<span>
|
'has-text-warning-dark': isSuperiorToEstimation
|
||||||
<span v-if="isSuperiorToEstimation">More</span><span v-else>Less</span>
|
}"
|
||||||
than expected.
|
>
|
||||||
</span>
|
The task took {{ duration }} minutes instead of
|
||||||
|
{{ task.totalEstimation }} minutes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="columns is-centered">
|
||||||
|
<div class="column is-half">
|
||||||
|
<textarea
|
||||||
|
name="record-notes"
|
||||||
|
id="record-notes"
|
||||||
|
rows="10"
|
||||||
|
:value="recordNotes"
|
||||||
|
@input="
|
||||||
|
//@ts-ignore
|
||||||
|
recordStore.updateRecordNotes(recordId, $event.target.value)
|
||||||
|
"
|
||||||
|
placeholder="Take notes while you're doing the task. It can be helpful at the end to retrieve your thought."
|
||||||
|
class="textarea"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
|
||||||
name="record-notes"
|
|
||||||
id="record-notes"
|
|
||||||
cols="30"
|
|
||||||
rows="10"
|
|
||||||
:value="recordNotes"
|
|
||||||
@input="
|
|
||||||
//@ts-ignore
|
|
||||||
recordStore.updateRecordNotes(recordId, $event.target?.value)
|
|
||||||
"
|
|
||||||
placeholder="Take notes while you're doing the task. It can be helpful at the end to retrieve your thought."
|
|
||||||
></textarea>
|
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.task-record {
|
|
||||||
table {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -11,20 +11,23 @@ const { duration } = useTaskRecordMetadata(props.record)
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<span>
|
<div class="task-record-link-container content">
|
||||||
<router-link
|
<router-link
|
||||||
class="task-record-link"
|
class="task-record-link button is-outlined"
|
||||||
:to="{
|
:to="{
|
||||||
name: 'record-view',
|
name: 'record-view',
|
||||||
params: { taskId: record.taskId, recordId: record.id }
|
params: { taskId: record.taskId, recordId: record.id }
|
||||||
}"
|
}"
|
||||||
>{{ formatDate(record.start) }}</router-link
|
>{{ formatDate(record.start) }}</router-link
|
||||||
>
|
>
|
||||||
{{ duration }} minutes
|
<span v-if="duration !== null"> {{ duration }} minutes </span>
|
||||||
</span>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.task-record-link {
|
.task-record-link-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -14,22 +14,20 @@ const newRecordId = createUuid()
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ol v-if="records.length" class="task-record-list">
|
<div class="content">
|
||||||
<li v-for="record in records" :key="record.id">
|
<ol v-if="records.length" class="task-record-list">
|
||||||
<task-record-link :record="record" />
|
<li v-for="record in records" :key="record.id">
|
||||||
</li>
|
<task-record-link :record="record" />
|
||||||
</ol>
|
</li>
|
||||||
<div v-else>No record yet</div>
|
</ol>
|
||||||
|
<div v-else>No record yet</div>
|
||||||
|
</div>
|
||||||
<router-link
|
<router-link
|
||||||
:to="{
|
:to="{
|
||||||
name: 'record-view',
|
name: 'record-view',
|
||||||
params: { taskId, recordId: newRecordId }
|
params: { taskId, recordId: newRecordId }
|
||||||
}"
|
}"
|
||||||
|
class="button is-primary is-light"
|
||||||
>start a new record</router-link
|
>start a new record</router-link
|
||||||
>
|
>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.task-record-list {
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ 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'
|
import type { TaskRecord } from '../models/task-record'
|
||||||
|
|
||||||
export const useTaskRecordMetadata = (record: TaskRecord | Ref<TaskRecord>) => {
|
export const useTaskRecordMetadata = (
|
||||||
|
record: TaskRecord | Ref<TaskRecord | null>
|
||||||
|
) => {
|
||||||
const duration = computed(() => {
|
const duration = computed(() => {
|
||||||
const recordValue = isRef(record) ? record.value : record
|
const recordValue = isRef(record) ? record.value : record
|
||||||
if (!recordValue?.end) {
|
if (!recordValue?.end) {
|
||||||
|
|||||||
@@ -16,8 +16,11 @@ export const useTaskRecordStore = defineStore('task-record-store', {
|
|||||||
records: {}
|
records: {}
|
||||||
}),
|
}),
|
||||||
actions: {
|
actions: {
|
||||||
addRecord(taskRecord: TaskRecord) {
|
addRecord(taskId: string, recordId: string) {
|
||||||
this.records[taskRecord.id] = taskRecord
|
if (recordId in this.records) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.records[recordId] = new TaskRecord(recordId, taskId)
|
||||||
},
|
},
|
||||||
removeRecord(recordId: string) {
|
removeRecord(recordId: string) {
|
||||||
delete this.records[recordId]
|
delete this.records[recordId]
|
||||||
@@ -51,7 +54,7 @@ export const useTaskRecordStore = defineStore('task-record-store', {
|
|||||||
},
|
},
|
||||||
endStepRecord(params: { recordId: string; stepId: string; end: ISODate }) {
|
endStepRecord(params: { recordId: string; stepId: string; end: ISODate }) {
|
||||||
const stepRecord =
|
const stepRecord =
|
||||||
this.records[params.recordId].stepRecords[params.stepId]
|
this.records[params.recordId]?.stepRecords[params.stepId]
|
||||||
|
|
||||||
if (!stepRecord) {
|
if (!stepRecord) {
|
||||||
return
|
return
|
||||||
@@ -83,6 +86,10 @@ export const useTaskRecordStore = defineStore('task-record-store', {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
endRecord(recordId: string) {
|
endRecord(recordId: string) {
|
||||||
|
if (!this.records[recordId]) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
this.records[recordId].end = toISODate(new Date())
|
this.records[recordId].end = toISODate(new Date())
|
||||||
this.currentStepId = null
|
this.currentStepId = null
|
||||||
},
|
},
|
||||||
@@ -100,6 +107,14 @@ export const useTaskRecordStore = defineStore('task-record-store', {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
reset(recordId: string) {
|
||||||
|
this.currentStepId = null
|
||||||
|
if (!this.records[recordId]) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.records[recordId].stepRecords = {}
|
||||||
|
this.records[recordId].end = undefined
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
getters: {
|
getters: {
|
||||||
@@ -109,22 +124,20 @@ export const useTaskRecordStore = defineStore('task-record-store', {
|
|||||||
.filter((record) => record.taskId === taskId)
|
.filter((record) => record.taskId === taskId)
|
||||||
.map((record) => TaskRecord.fromRecordable(record))
|
.map((record) => TaskRecord.fromRecordable(record))
|
||||||
},
|
},
|
||||||
createAndRetrieveTaskRecord() {
|
getTaskRecord() {
|
||||||
return (taskId: string, recordId: string): TaskRecord => {
|
return (recordId: string): TaskRecord | null => {
|
||||||
const hasTaskRecord = !!this.records[recordId]
|
const hasTaskRecord = !!this.records[recordId]
|
||||||
|
|
||||||
if (hasTaskRecord) {
|
if (hasTaskRecord) {
|
||||||
return TaskRecord.fromRecordable(this.records[recordId])
|
return TaskRecord.fromRecordable(this.records[recordId])
|
||||||
}
|
}
|
||||||
|
|
||||||
const newTaskRecord = new TaskRecord(recordId, taskId)
|
return null
|
||||||
this.records[recordId] = newTaskRecord
|
|
||||||
|
|
||||||
return newTaskRecord
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
getRecord() {
|
getRecord() {
|
||||||
return (recordId: string) => this.records[recordId] ?? null
|
return (recordId: string): Recordable | null =>
|
||||||
|
this.records[recordId] ?? null
|
||||||
},
|
},
|
||||||
getStepRecord() {
|
getStepRecord() {
|
||||||
return (recordId: string, stepId: string): StepRecordable | null =>
|
return (recordId: string, stepId: string): StepRecordable | null =>
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { createUuid } from '@/shared/create-uuid'
|
import { createUuid } from '@/shared/create-uuid'
|
||||||
import { faker } from '@faker-js/faker'
|
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { createStepFixture } from '../models/step.fixture'
|
import type { Stepable } from '../interfaces/stepable'
|
||||||
import { Task } from '../models/task'
|
import { Task } from '../models/task'
|
||||||
import { useTaskStore } from '../stores/useTask.store'
|
import { useTaskStore } from '../stores/useTask.store'
|
||||||
import StepInput from './StepInput.vue'
|
import StepInput from './StepInput.vue'
|
||||||
@@ -13,13 +12,11 @@ const router = useRouter()
|
|||||||
|
|
||||||
const id = createUuid()
|
const id = createUuid()
|
||||||
|
|
||||||
const title = ref(faker.animal.bird())
|
const title = ref('')
|
||||||
const link = ref(faker.internet.url())
|
const link = ref('')
|
||||||
const steps = ref(
|
|
||||||
Array.from({ length: Math.floor(Math.random() * 10) }, () =>
|
const steps = ref<Stepable[]>([])
|
||||||
createStepFixture()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
const totalEstimation = computed(() =>
|
const totalEstimation = computed(() =>
|
||||||
steps.value.map((step) => step.estimation).reduce((a, b) => a + b, 0)
|
steps.value.map((step) => step.estimation).reduce((a, b) => a + b, 0)
|
||||||
)
|
)
|
||||||
@@ -46,21 +43,29 @@ const saveTask = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="columns is-centered">
|
||||||
<h1>Create a task</h1>
|
<div class="column is-half">
|
||||||
<h2>Estimation: {{ totalEstimation }} minutes</h2>
|
<h1 class="title">Create a task</h1>
|
||||||
<form @submit.prevent="saveTask">
|
<h2 class="subtitle">Estimation: {{ totalEstimation }} minutes</h2>
|
||||||
<button type="submit">save task</button>
|
<form @submit.prevent="saveTask">
|
||||||
<div>
|
<div class="field">
|
||||||
<label for="title">Title</label>
|
<label class="label" for="title">Title</label>
|
||||||
<input type="text" id="title" v-model="title" />
|
<div class="control">
|
||||||
</div>
|
<input class="input" type="text" id="title" v-model="title" />
|
||||||
<div>
|
</div>
|
||||||
<label for="link">User story link</label>
|
</div>
|
||||||
<input type="text" id="link" v-model="link" />
|
<div class="field">
|
||||||
</div>
|
<label class="label" for="link">User story link</label>
|
||||||
<StepInput v-model="steps" />
|
<div class="control">
|
||||||
</form>
|
<input class="input" type="text" id="link" v-model="link" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<StepInput v-model="steps" />
|
||||||
|
<button class="button is-primary is-fullwidth" type="submit">
|
||||||
|
save
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -29,15 +29,18 @@ const stepsTextarea = computed({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="step-input">
|
<div class="step-input field">
|
||||||
<label for="steps">steps</label>
|
<label class="label" for="steps">steps</label>
|
||||||
<textarea
|
<div class="control">
|
||||||
id="steps"
|
<textarea
|
||||||
name="steps"
|
id="steps"
|
||||||
v-model="stepsTextarea"
|
name="steps"
|
||||||
cols="40"
|
v-model="stepsTextarea"
|
||||||
rows="20"
|
rows="15"
|
||||||
></textarea>
|
class="textarea"
|
||||||
|
placeholder="- [step] | <minutes you estimate it will take>"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -6,18 +6,25 @@ const taskStore = useTaskStore()
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ul class="task-list">
|
<div class="content">
|
||||||
<li v-for="task in taskStore.recentTasks" :key="task.id">
|
<ul class="task-list">
|
||||||
<router-link :to="{ name: 'task-view', params: { id: task.id } }">{{
|
<li v-for="task in taskStore.recentTasks" :key="task.id">
|
||||||
task.title
|
<router-link
|
||||||
}}</router-link>
|
:to="{ name: 'task-view', params: { id: task.id } }"
|
||||||
| {{ task.totalEstimation }} minutes |
|
class="button is-link is-outlined"
|
||||||
{{ formatDate(task.date) }}
|
>{{ task.title }}</router-link
|
||||||
</li>
|
>
|
||||||
</ul>
|
<span> {{ task.totalEstimation }} minutes </span>
|
||||||
|
<span>{{ formatDate(task.date) }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.task-list {
|
.task-list li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -7,8 +7,12 @@ const taskStore = useTaskStore()
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main>
|
<main>
|
||||||
<router-link :to="{ name: 'new-task' }">New task</router-link>
|
<router-link :to="{ name: 'new-task' }" class="button is-primary"
|
||||||
|
>New task</router-link
|
||||||
|
>
|
||||||
<task-list />
|
<task-list />
|
||||||
<button @click="() => taskStore.reset()">reset list</button>
|
<button class="button is-danger" @click="() => taskStore.reset()">
|
||||||
|
reset list
|
||||||
|
</button>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -12,8 +12,3 @@ defineProps<{
|
|||||||
<TaskRecord :task-id="taskId" :record-id="recordId" />
|
<TaskRecord :task-id="taskId" :record-id="recordId" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.record-view {
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -14,27 +14,24 @@ const task = computed(() => taskStore.getTask(props.id))
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="task-view" v-if="task">
|
<div class="task-view" v-if="task">
|
||||||
<h1>{{ task.title }}</h1>
|
<h1 class="title">{{ task.title }}</h1>
|
||||||
<h2>{{ task.totalEstimation }} minutes</h2>
|
<h2 class="subtitle">{{ task.totalEstimation }} minutes</h2>
|
||||||
<a
|
<a
|
||||||
v-if="task.link"
|
v-if="task.link"
|
||||||
:href="task.link"
|
:href="task.link"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>User Story link</a
|
class="button is-link"
|
||||||
|
>user story link</a
|
||||||
>
|
>
|
||||||
<ul>
|
<div class="content is-large">
|
||||||
<li v-for="step in task.steps" :key="step.id">
|
<ol>
|
||||||
<div>{{ step.title }} | {{ step.estimation }}</div>
|
<li v-for="step in task.steps" :key="step.id">
|
||||||
</li>
|
<div>{{ step.title }} | {{ step.estimation }}</div>
|
||||||
</ul>
|
</li>
|
||||||
<hr />
|
</ol>
|
||||||
|
</div>
|
||||||
<task-record-list :task-id="id" />
|
<task-record-list :task-id="id" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else>Task not found</div>
|
<div v-else>Task not found</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.task-view {
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
Reference in New Issue
Block a user