Feat/initial plan (#6)

* chore: upgrade libs

* fix outdated tests

* init edit steps form

* tasks have now initialPlan

* simpler to directly have an history and put initialPlan and steps has getters

* consistent test script names

* display initial plan to task view

* display initial if only it exists (next when only it changes

* display initial plan only if it chanegd
This commit is contained in:
Julien Calixte
2024-02-24 12:20:22 +01:00
committed by GitHub
parent 5e5015d14e
commit 53ce6f5e16
26 changed files with 2190 additions and 1862 deletions

View File

@@ -4,52 +4,53 @@
"private": true,
"scripts": {
"dev": "vite",
"build": "run-p type-check build-only",
"build": "run-p test:types build-only",
"preview": "vite preview",
"test": "pnpm lint && pnpm type-check && pnpm test:unit",
"test:types": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false",
"test:unit": "vitest",
"test:ui": "vitest --ui",
"test": "pnpm lint && pnpm test:types && pnpm test:unit",
"build-only": "vite build",
"type-check": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
"format": "prettier --write src/"
},
"dependencies": {
"@vueuse/core": "^10.6.1",
"@vueuse/math": "^10.6.1",
"@vueuse/core": "^10.7.2",
"@vueuse/math": "^10.7.2",
"bulma": "^0.9.4",
"html-to-image": "^1.11.11",
"nanoid": "^5.0.3",
"nanoid": "^5.0.5",
"pinia": "^2.1.7",
"pinia-plugin-persistedstate": "^3.2.0",
"pinia-plugin-persistedstate": "^3.2.1",
"suretype": "^3.3.1",
"vue": "^3.3.9",
"vue": "^3.4.19",
"vue-router": "^4.2.5"
},
"devDependencies": {
"@faker-js/faker": "^7.6.0",
"@pinia/testing": "^0.0.16",
"@rushstack/eslint-patch": "^1.2.0",
"@types/jsdom": "^21.1.1",
"@types/node": "^18.14.2",
"@vitejs/plugin-vue": "^4.1.0",
"@vitest/browser": "^0.31.0",
"@vitest/ui": "^0.31.0",
"@vue/eslint-config-prettier": "^7.1.0",
"@vue/eslint-config-typescript": "^11.0.3",
"@vue/test-utils": "^2.3.2",
"@vue/tsconfig": "^0.1.3",
"eslint": "^8.39.0",
"eslint-plugin-vue": "^9.11.0",
"jsdom": "^21.1.1",
"@faker-js/faker": "^8.4.1",
"@pinia/testing": "^0.1.3",
"@rushstack/eslint-patch": "^1.7.2",
"@tsconfig/node18": "^18.2.2",
"@types/jsdom": "^21.1.6",
"@types/node": "^18.19.17",
"@vitejs/plugin-vue": "^5.0.4",
"@vitest/browser": "^1.3.0",
"@vitest/ui": "^1.3.0",
"@vue/eslint-config-prettier": "^9.0.0",
"@vue/eslint-config-typescript": "^12.0.0",
"@vue/test-utils": "^2.4.4",
"@vue/tsconfig": "^0.5.1",
"eslint": "^8.56.0",
"eslint-plugin-vue": "^9.21.1",
"jsdom": "^24.0.0",
"npm-run-all": "^4.1.5",
"prettier": "^2.8.8",
"sass": "^1.62.1",
"typescript": "~4.8.4",
"vite": "^4.3.1",
"vite-plugin-pwa": "^0.14.7",
"vitest": "^0.30.1",
"vue-tsc": "^1.4.4",
"webdriverio": "^8.8.8"
"prettier": "^3.2.5",
"sass": "^1.71.0",
"typescript": "~5.3.3",
"vite": "^5.1.3",
"vite-plugin-pwa": "^0.19.0",
"vitest": "^1.3.0",
"vue-tsc": "^1.8.27",
"webdriverio": "^8.32.2"
}
}

3501
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
import type { Stepable } from '@/modules/task/interfaces/stepable'
import StepRecord from '@/modules/record/components/StepRecord.vue'
defineProps<{
id: string
steps: Stepable[]
}>()
</script>
<template>
<table class="table record-step-table is-striped is-hoverable">
<thead>
<tr>
<th>#</th>
<th>status</th>
<th>task</th>
<th>estimation</th>
<th>actual</th>
</tr>
</thead>
<tbody>
<step-record
v-for="(step, key) in steps"
:task-id="id"
:key="step.id"
:step-id="step.id"
:step-number="key + 1"
/>
</tbody>
</table>
</template>
<style scoped lang="scss">
.record-step-table {
.step-item {
display: flex;
align-items: center;
gap: 1rem;
justify-content: space-between;
max-width: 600px;
}
}
</style>

View File

@@ -1,10 +1,9 @@
import { fixtureTask } from '@/modules/task/models/task.fixture'
import { router } from '@/router'
import { toISODate } from '@/shared/types/date'
import { withPlugins } from '@/tests/utils'
import { faker } from '@faker-js/faker'
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it } from 'vitest'
import { fixtureRecordable } from '../interfaces/recordable.fixture'
import TaskRecordPreview from './TaskRecordPreview.vue'
@@ -14,7 +13,7 @@ const mountTaskRecordPreview = (withRecord = false) => {
const record = fixtureRecordable({
taskId: task.id,
stepRecords: {
[faker.datatype.uuid()]: {
[faker.string.uuid()]: {
start: toISODate(new Date('2023-04-17T19:00:00.000Z')),
end
}
@@ -45,12 +44,6 @@ const mountTaskRecordPreview = (withRecord = false) => {
}
describe('Task Record Preview', () => {
it('displays a start recording', () => {
const { wrapper } = mountTaskRecordPreview()
expect(wrapper.text()).toContain('start recording')
})
it('displays no record yet if there is no record', () => {
const { wrapper } = mountTaskRecordPreview()
@@ -60,22 +53,6 @@ describe('Task Record Preview', () => {
it('displays the duration of a recorded task', () => {
const { wrapper } = mountTaskRecordPreview(true)
expect(wrapper.text()).toContain('last time: 60 minutes')
})
it('navigates to recording view on click', async () => {
const { task, wrapper } = mountTaskRecordPreview()
const spyOnPush = vi.spyOn(router, 'push')
await wrapper.find('a').trigger('click')
expect(spyOnPush).toHaveBeenCalledTimes(1)
expect(spyOnPush).toHaveBeenCalledWith({
name: 'record-view',
params: {
taskId: task.id
}
})
expect(wrapper.text()).toContain('Last record took 60 minutes.')
})
})

View File

@@ -17,7 +17,7 @@ describe('use task record metadata', () => {
const end = toISODate(new Date('2023-04-17T20:00:00.000Z'))
const record = fixtureRecordable({
stepRecords: {
[faker.datatype.uuid()]: {
[faker.string.uuid()]: {
start: toISODate(new Date('2023-04-17T19:00:00.000Z')),
end
}

View File

@@ -5,13 +5,13 @@ import type { Recordable } from './recordable'
export const fixtureRecordable = (
partialRecordable?: Partial<Recordable>
): Recordable => ({
taskId: partialRecordable?.taskId ?? faker.datatype.uuid(),
taskId: partialRecordable?.taskId ?? faker.string.uuid(),
notes: partialRecordable?.notes ?? faker.lorem.paragraph(),
start: partialRecordable?.start ?? toISODate(faker.datatype.datetime()),
start: partialRecordable?.start ?? toISODate(faker.date.anytime()),
breakTime: partialRecordable?.breakTime ?? undefined,
stepRecords: partialRecordable?.stepRecords ?? {
[faker.datatype.uuid()]: {
start: toISODate(faker.datatype.datetime())
[faker.string.uuid()]: {
start: toISODate(faker.date.anytime())
}
},
currentStepId: null,

View File

@@ -5,11 +5,11 @@ import type { TimeRange } from './time-range'
export const fixtureTimeRange = (
partialTimeRange?: Partial<TimeRange>
): TimeRange => {
const [start, end] = faker.date.betweens(
toISODate(faker.date.past(1)),
toISODate(new Date()),
2
)
const [start, end] = faker.date.betweens({
from: toISODate(faker.date.past({ years: 1 })),
to: toISODate(new Date()),
count: 2
})
return {
start: partialTimeRange?.start ?? toISODate(start),

View File

@@ -9,13 +9,13 @@ import { TaskRecord } from './task-record'
describe('Task Record', () => {
it('creates a Record from a Recordable', () => {
const recordable: Recordable = fixtureRecordable({
taskId: faker.datatype.uuid(),
taskId: faker.string.uuid(),
notes: faker.lorem.paragraphs(),
start: toISODate(faker.date.past(1)),
start: toISODate(faker.date.past({ years: 1 })),
end: toISODate(faker.date.past()),
breakTime: fixtureTimeRange(),
stepRecords: {
[faker.datatype.uuid()]: fixtureTimeRange()
[faker.string.uuid()]: fixtureTimeRange()
}
})

View File

@@ -0,0 +1,70 @@
<script setup lang="ts">
import type { Stepable } from '@/modules/task/interfaces/stepable'
defineProps<{
id: string
steps: Stepable[]
}>()
</script>
<template>
<table class="table record-step-table is-striped is-hoverable">
<thead>
<tr>
<th>#</th>
<th>status</th>
<th>task</th>
<th>estimation</th>
</tr>
</thead>
<tbody>
<tr class="step-row" v-for="(step, index) in steps" :key="step.id">
<td class="td-rank">
<div class="rank">
{{ index + 1 }}
</div>
</td>
<td class="step-title">
{{ step.title }}
</td>
<td class="estimation minutes">{{ step.estimation }} min</td>
</tr>
</tbody>
</table>
</template>
<style scoped lang="scss">
.step-table {
.step-row {
.status {
text-align: center;
div {
margin: auto;
}
}
.td-rank {
&.added-afterward {
background: #fbc124;
}
}
.rank {
text-align: right;
display: flex;
justify-content: end;
align-items: center;
min-height: 30px;
}
.step-title {
padding-right: 1rem;
}
.minutes {
text-align: right;
}
}
}
</style>

View File

@@ -0,0 +1,46 @@
<script setup lang="ts">
import type { Stepable } from '@/modules/task/interfaces/stepable'
import { ref, watch } from 'vue'
import StepInput from './StepInput.vue'
const props = defineProps<{
isActive: boolean
initialSteps: Stepable[]
}>()
const emits = defineEmits<{
(event: 'submit', steps: Stepable[]): void
(event: 'close'): void
}>()
const steps = ref<Stepable[]>(props.initialSteps)
watch(props.initialSteps, (initialSteps) => {
steps.value = initialSteps
})
const save = () => {
emits('submit', steps.value)
}
</script>
<template>
<div class="modal" :class="{ 'is-active': isActive }">
<div class="modal-background" @click="$emit('close')"></div>
<div class="edit-steps-form modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Edit steps</p>
<button
class="delete"
aria-label="close"
@click="$emit('close')"
></button>
</header>
<section class="modal-card-body">
<step-input v-if="isActive" v-model="steps" size="small" />
</section>
<footer class="modal-card-foot">
<button class="button is-primary" @click="save">add</button>
<button class="button" @click="$emit('close')">cancel</button>
</footer>
</div>
</div>
</template>

View File

@@ -6,5 +6,5 @@ const id = createUuid()
</script>
<template>
<task-form :id="id" />
<task-form :id="id" :action="'new'" />
</template>

View File

@@ -1,6 +1,5 @@
<script setup lang="ts">
import EstimationTimeArrival from '@/components/EstimationTimeArrival.vue'
import { createUuid } from '@/shared/create-uuid'
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import type { Stepable } from '../interfaces/stepable'
@@ -8,48 +7,23 @@ import type { Taskable } from '../interfaces/taskable'
import { Task } from '../models/task'
import { useTaskStore } from '../stores/useTask.store'
import StepInput from './StepInput.vue'
import { exampleSteps } from '@/modules/task/examples/steps'
const store = useTaskStore()
const router = useRouter()
const props = defineProps<{ id: string; initialTask?: Taskable }>()
const props = defineProps<{
id: string
initialTask?: Taskable
}>()
const id = computed(() => props.id)
const hasTasks = computed(() => store.tasks.length > 0)
const exampleSteps: Stepable[] = [
{
id: createUuid(),
title: 'create math.test file, test add function for simple cases',
estimation: 3
},
{
id: createUuid(),
title: 'create the math file, implement add function',
estimation: 3
},
{
id: createUuid(),
title: 'commit',
estimation: 1
},
{
id: createUuid(),
title: 'TDD for the multiply function',
estimation: 8
},
{
id: createUuid(),
title: 'write documentation',
estimation: 10
},
{
id: createUuid(),
title: 'commit, push and create the PR',
estimation: 5
}
]
const steps = ref<Stepable[]>(
props.initialTask?.steps ?? (hasTasks.value ? [] : exampleSteps)
props.initialTask
? Task.fromTaskable(props.initialTask).steps
: hasTasks.value
? []
: exampleSteps
)
const title = ref(props.initialTask?.title ?? '')
@@ -60,23 +34,24 @@ const totalEstimation = computed(() =>
)
const saveTask = () => {
const task = new Task(id.value, title.value)
const task = new Task(id.value, title.value, props.initialTask?.stepHistory)
if (link.value) {
task.link = link.value
}
task.addSteps(...steps.value)
task.newSteps(steps.value)
if (Task.validate(task)) {
store.saveTask(task)
router.push({
name: 'task-view',
params: {
id: id.value
}
})
if (!Task.validate(task)) {
return false
}
return false
store.saveTask(task)
router.push({
name: 'task-view',
params: {
id: id.value
}
})
}
const isValid = computed(() => title.value && steps.value.length > 0)

View File

@@ -0,0 +1,35 @@
import type { Stepable } from '@/modules/task/interfaces/stepable'
import { createUuid } from '@/shared/create-uuid'
export const exampleSteps: Stepable[] = [
{
id: createUuid(),
title: 'create math.test file, test add function for simple cases',
estimation: 3
},
{
id: createUuid(),
title: 'create the math file, implement add function',
estimation: 3
},
{
id: createUuid(),
title: 'commit',
estimation: 1
},
{
id: createUuid(),
title: 'TDD for the multiply function',
estimation: 8
},
{
id: createUuid(),
title: 'write documentation',
estimation: 10
},
{
id: createUuid(),
title: 'commit, push and create the PR',
estimation: 5
}
]

View File

@@ -6,5 +6,5 @@ export interface Taskable {
title: string
date: ISODate
link: string | null
steps: Stepable[]
stepHistory?: Stepable[][]
}

View File

@@ -2,11 +2,11 @@ import type { Stepable } from '@/modules/task/interfaces/stepable'
import { faker } from '@faker-js/faker'
export const fixtureStep = (partialStep?: Partial<Stepable>) => ({
id: partialStep?.id ?? faker.datatype.uuid(),
id: partialStep?.id ?? faker.string.uuid(),
title: partialStep?.title ?? faker.animal.bird(),
estimation:
partialStep?.estimation ??
faker.datatype.number({
faker.number.int({
min: 0,
max: 40
})

View File

@@ -9,6 +9,6 @@ export const fixtureTask = (
...steps: Stepable[]
) =>
new Task(
partialTask?.id ?? faker.datatype.uuid(),
partialTask?.id ?? faker.string.uuid(),
partialTask?.title ?? faker.animal.bird()
).addSteps(...(steps ?? fixtureStep()))

View File

@@ -7,7 +7,7 @@ import { describe, expect, it } from 'vitest'
describe('Task', () => {
it('has a simple id', () => {
const uuid = faker.datatype.uuid()
const uuid = faker.string.uuid()
const task = new Task(uuid, faker.animal.bear())
@@ -16,11 +16,11 @@ describe('Task', () => {
it('allows a new task from a taskable object', () => {
const taskable: Taskable = {
id: faker.datatype.uuid(),
id: faker.string.uuid(),
date: toISODate(faker.date.recent()),
title: faker.animal.lion(),
link: faker.internet.url(),
steps: [fixtureStep()]
stepHistory: [[fixtureStep()]]
}
const task = Task.fromTaskable(taskable)
@@ -28,7 +28,7 @@ describe('Task', () => {
})
it('adds steps and removes them', () => {
const task = new Task(faker.datatype.uuid(), faker.color.human())
const task = new Task(faker.string.uuid(), faker.color.human())
const [firstStep, secondStep] = [fixtureStep(), fixtureStep()]
@@ -44,7 +44,7 @@ describe('Task', () => {
})
it('must have an id, a title and steps to be valid', () => {
const task = new Task(faker.datatype.uuid(), faker.color.human())
const task = new Task(faker.string.uuid(), faker.color.human())
expect(Task.validate(task)).toEqual(false)
task.addSteps(fixtureStep())
@@ -52,7 +52,7 @@ describe('Task', () => {
})
it('calculates the total estimation of steps', () => {
const task = new Task(faker.datatype.uuid(), faker.color.human())
const task = new Task(faker.string.uuid(), faker.color.human())
task.addSteps(
fixtureStep({ estimation: 1 }),
@@ -62,4 +62,29 @@ describe('Task', () => {
expect(task.totalEstimation).toEqual(6)
})
it('save the initial plan even after the task is updated', () => {
const steps = [
fixtureStep({ estimation: 1 }),
fixtureStep({ estimation: 2 })
]
const task = new Task(faker.string.uuid(), faker.color.human(), [steps])
task.updateSteps([
fixtureStep({ estimation: 3 }),
fixtureStep({ estimation: 1 }),
fixtureStep({ estimation: 3 })
])
expect(steps).toEqual(task.initialPlan)
})
it('says if the task was updated', () => {
const task = new Task(faker.string.uuid(), faker.color.human())
expect(task.wasUpdated).toEqual(false)
task.addSteps(fixtureStep())
expect(task.wasUpdated).toEqual(true)
})
})

View File

@@ -3,14 +3,37 @@ import type { Taskable } from '@/modules/task/interfaces/taskable'
import { toISODate } from '@/shared/types/date'
export class Task implements Taskable {
public readonly stepHistory: Stepable[][] = []
public date = toISODate(new Date())
public steps: Stepable[] = []
public link: string | null = null
constructor(public readonly id: string, public readonly title: string) {}
constructor(
public readonly id: string,
public readonly title: string,
history: Stepable[][] = []
) {
this.stepHistory = history
}
public get initialPlan() {
return this.stepHistory[0] ?? []
}
public get steps(): Stepable[] {
return this.stepHistory[this.stepHistory.length - 1] ?? []
}
public get wasUpdated() {
return this.stepHistory.length > 0
}
public addSteps(...steps: Stepable[]) {
this.steps.push(...steps)
this.stepHistory.push([...this.steps, ...steps])
return this
}
public newSteps(steps: Stepable[]) {
this.stepHistory.push(steps)
return this
}
@@ -23,7 +46,12 @@ export class Task implements Taskable {
return this
}
this.steps.splice(index, 1)
this.stepHistory.push(this.steps.filter((_, i) => i !== index))
return this
}
public updateSteps(steps: Stepable[]) {
this.stepHistory.push(steps)
return this
}
@@ -32,15 +60,14 @@ export class Task implements Taskable {
}
public static fromTaskable(taskable: Taskable) {
const task = new Task(taskable.id, taskable.title)
const task = new Task(taskable.id, taskable.title, taskable.stepHistory)
task.link = taskable.link
task.date = taskable.date
task.addSteps(...taskable.steps)
return task
}
public static validate(task: Taskable) {
return !!task.id && !!task.title && task.steps.length > 0
return !!task.id && !!task.title && Task.fromTaskable(task).steps.length > 0
}
}

View File

@@ -18,25 +18,29 @@ export const useTaskStore = defineStore('task-store', {
this.tasks.push(task)
},
addStepsToTask(taskId: string, steps: Stepable[], fromStepId: string) {
const task = this.tasks.find((t) => t.id === taskId)
this.tasks = this.tasks.map((task) => {
if (task.id !== taskId) {
return task
}
if (!task) {
return
}
const fromStepIndex = Task.fromTaskable(task).steps.findIndex(
(s) => s.id === fromStepId
)
const fromStepIndex = task.steps.findIndex((s) => s.id === fromStepId)
if (fromStepIndex < 0) {
return task
}
if (fromStepIndex < 0) {
return
}
const newTask = Task.fromTaskable(task)
const newSteps = [
...task.steps.slice(0, fromStepIndex + 1),
...steps.map((step) => ({ ...step, addedAfterward: true })),
...task.steps.slice(fromStepIndex + 1)
]
newTask.newSteps([
...newTask.steps.slice(0, fromStepIndex + 1),
...steps.map((step) => ({ ...step, addedAfterward: true })),
...newTask.steps.slice(fromStepIndex + 1)
])
task.steps = newSteps
return newTask
})
},
reset() {
this.tasks = []

View File

@@ -5,7 +5,7 @@ import type { TaskStoreState } from '@/modules/task/stores/useTask.store'
import { router } from '@/router'
import { toISODate } from '@/shared/types/date'
import { createTestingPinia } from '@pinia/testing'
import type { GlobalMountOptions } from '@vue/test-utils/dist/types'
import type { GlobalMountOptions } from 'node_modules/@vue/test-utils/dist/types'
import { vi } from 'vitest'
export interface InitialState {

View File

@@ -27,7 +27,7 @@ const resetTasks = () => {
</div>
<task-list class="column task-list" />
</div>
<!--
<!--
<footer>
<p>
Made with <img src="@/assets/icons/love.svg" alt="love" /> by

View File

@@ -6,7 +6,8 @@ import { useCopyRecord } from '@/modules/record/hooks/useCopyRecord.hook'
import { useTaskStore } from '@/modules/task/stores/useTask.store'
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import StepRecord from '@/modules/record/components/StepRecord.vue'
import RecordStepTable from '@/modules/record/components/RecordStepTable.vue'
import StepTable from '@/modules/step/components/StepTable.vue'
const props = defineProps<{
id: string
@@ -84,40 +85,17 @@ const { canShareTask, taskCopied, shareTask } = useCopyRecord(task)
<estimation-time-arrival :estimation="task.totalEstimation" />
</h2>
<task-record-preview :task-id="id" />
<table class="table is-striped is-hoverable">
<thead>
<tr>
<th>#</th>
<th>status</th>
<th>task</th>
<th>estimation</th>
<th>actual</th>
</tr>
</thead>
<tbody>
<step-record
v-for="(step, key) in task.steps"
:task-id="id"
:key="step.id"
:step-id="step.id"
:step-number="key + 1"
/>
</tbody>
</table>
<record-step-table :id="id" :steps="task.steps" />
<details v-if="task.initialPlan && task.wasUpdated">
<summary>Initial plan</summary>
<step-table :id="id" :steps="task.initialPlan" />
</details>
</div>
</div>
<task-not-found v-else />
</template>
<style scoped>
.step-item {
display: flex;
align-items: center;
gap: 1rem;
justify-content: space-between;
max-width: 600px;
}
.actions {
float: right;
}

View File

@@ -1,5 +1,5 @@
{
"extends": "@vue/tsconfig/tsconfig.web.json",
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {

View File

@@ -1,5 +1,8 @@
{
"extends": "@vue/tsconfig/tsconfig.node.json",
"extends": [
"@tsconfig/node18/tsconfig.json",
"@vue/tsconfig/tsconfig.json"
],
"include": [
"vite.config.*",
"vitest.config.*",

View File

@@ -1,7 +1,7 @@
import { fileURLToPath } from 'node:url'
import { mergeConfig } from 'vite'
import { configDefaults, defineConfig } from 'vitest/config'
import viteConfig from './vite.config'
import viteConfig from './vite.config.mjs'
export default mergeConfig(
viteConfig,