Merge branch 'main' of github.com:jcalixte/loopycode

This commit is contained in:
Julien Calixte
2026-01-26 19:49:47 +01:00
18 changed files with 755 additions and 8887 deletions

View File

@@ -1 +1 @@
20.12.2 v24.11.1

69
CLAUDE.md Normal file
View File

@@ -0,0 +1,69 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Fail Well is a Progressive Web App (PWA) for tracking developer feedback loops during coding tasks. It helps compare planned steps/time vs actual execution, promoting iterative improvement.
A task is a set of items and it looks like this:
```md
- First task | 5
- Second task | 4
```
The last part after the last `|` is the estimated time of the task item in minutes.
## Commands
```bash
# Development
pnpm dev # Start Vite dev server
# Building
pnpm build # Full build (types + tests + bundle)
pnpm build-only # Vite build only
# Testing
pnpm test # Full test suite (lint + types + unit)
pnpm test:unit # Vitest unit tests only
pnpm test:ui # Vitest with UI dashboard
pnpm test:types # TypeScript type checking
# Code Quality
pnpm lint # ESLint with auto-fix
pnpm format # Prettier formatting
# Commits
pnpm commit # Commitizen conventional commit prompt
```
## Architecture
**Tech Stack:** Vue 3 + TypeScript + Vite + Pinia + Bulma CSS
**Module Structure:**
- `src/modules/` - Feature modules with self-contained components, stores, models, and services
- `record/` - Task execution tracking (time, steps, break calculation)
- `task/` - Task CRUD, step history, duplication
- `step/` - Step-related components
- `src/views/` - Page-level components organized by feature
- `src/shared/` - Shared types and hooks
- `src/router/` - Vue Router configuration
**State Management:** Pinia stores with `pinia-plugin-persistedstate` for local persistence
**Key Routes:**
- `/` - Task list
- `/task/:id` - Task details
- `/task/:id/edit` - Edit steps
- `/task/:taskId/record` - Record task execution
## Testing
Uses Vitest with jsdom environment. Test files are colocated with source files using `.spec.ts` suffix.
Run single test file: `pnpm vitest run path/to/file.spec.ts`

View File

@@ -16,27 +16,27 @@
"commit": "cz" "commit": "cz"
}, },
"dependencies": { "dependencies": {
"@types/md5": "^2.3.5",
"@vueuse/core": "^10.7.2", "@vueuse/core": "^10.7.2",
"@vueuse/math": "^10.7.2", "@vueuse/math": "^10.7.2",
"bulma": "^0.9.4", "bulma": "^0.9.4",
"html-to-image": "^1.11.11", "html-to-image": "^1.11.13",
"md5": "^2.3.0", "md5": "^2.3.0",
"nanoid": "^5.0.5", "nanoid": "^5.1.6",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"pinia-plugin-persistedstate": "^3.2.1", "pinia-plugin-persistedstate": "^3.2.1",
"suretype": "^3.3.1", "suretype": "^3.3.1",
"vue": "^3.4.19", "vue": "^3.5.27",
"vue-diff": "^1.2.4", "vue-diff": "^1.2.4",
"vue-router": "^4.2.5" "vue-router": "^4.6.4"
}, },
"devDependencies": { "devDependencies": {
"@faker-js/faker": "^8.4.1", "@types/md5": "^2.3.6",
"@faker-js/faker": "^10.2.0",
"@pinia/testing": "^0.1.3", "@pinia/testing": "^0.1.3",
"@rushstack/eslint-patch": "^1.7.2", "@rushstack/eslint-patch": "^1.15.0",
"@tsconfig/node18": "^18.2.2", "@tsconfig/node18": "^18.2.6",
"@types/jsdom": "^21.1.6", "@types/jsdom": "^27.0.0",
"@types/node": "^18.19.17", "@types/node": "^25.0.10",
"@vitejs/plugin-vue": "^5.0.4", "@vitejs/plugin-vue": "^5.0.4",
"@vitest/browser": "^1.3.0", "@vitest/browser": "^1.3.0",
"@vitest/ui": "^1.3.0", "@vitest/ui": "^1.3.0",
@@ -49,13 +49,13 @@
"eslint-plugin-vue": "^9.21.1", "eslint-plugin-vue": "^9.21.1",
"jsdom": "^24.0.0", "jsdom": "^24.0.0",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"prettier": "^3.2.5", "prettier": "^3.8.1",
"sass": "^1.71.0", "sass": "^1.97.3",
"typescript": "~5.3.3", "typescript": "~5.9.3",
"vite": "^5.1.3", "vite": "^5.1.3",
"vite-plugin-pwa": "^0.19.0", "vite-plugin-pwa": "^0.19.0",
"vitest": "^1.3.0", "vitest": "^1.3.0",
"vue-tsc": "^1.8.27", "vue-tsc": "^3.2.3",
"webdriverio": "^8.32.2" "webdriverio": "^8.32.2"
} }
} }

8830
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

3
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,3 @@
onlyBuiltDependencies:
- '@parcel/watcher'
- vue-demi

View File

@@ -17,6 +17,8 @@ import { RouterLink, RouterView } from 'vue-router'
</main> </main>
<footer> <footer>
<router-link to="/about">about</router-link>
<span class="separator">|</span>
<a <a
href="https://github.com/jcalixte/failwell/issues" href="https://github.com/jcalixte/failwell/issues"
target="_blank" target="_blank"
@@ -52,4 +54,9 @@ footer {
text-align: right; text-align: right;
font-size: 0.8rem; font-size: 0.8rem;
} }
footer .separator {
margin: 0 0.5rem;
color: #ccc;
}
</style> </style>

View File

@@ -183,7 +183,7 @@ onUnmounted(() => {
<div class="column message"> <div class="column message">
<p><kbd>s</kbd>: start record</p> <p><kbd>s</kbd>: start record</p>
<p><kbd>n</kbd>: next step</p> <p><kbd>n</kbd>: next step</p>
<p><kbd>p</kbd>: pause</p> <p><kbd>p</kbd>: pause/resume</p>
</div> </div>
</div> </div>
<NewStepsForm <NewStepsForm

View File

@@ -2,7 +2,7 @@
import { useTaskStore } from '@/modules/task/stores/useTask.store' import { useTaskStore } from '@/modules/task/stores/useTask.store'
import { formatDiffInMinutes } from '@/shared/format-date' import { formatDiffInMinutes } from '@/shared/format-date'
import { toISODate } from '@/shared/types/date' import { toISODate } from '@/shared/types/date'
import { computed, onUnmounted, ref } from 'vue' import { computed, nextTick, onUnmounted, ref } from 'vue'
import { useTaskRecordStore } from '../stores/useTaskRecordStore' import { useTaskRecordStore } from '../stores/useTaskRecordStore'
import { is10PercentOffThanEstimation } from '@/modules/record/services/compare-with-estimation' import { is10PercentOffThanEstimation } from '@/modules/record/services/compare-with-estimation'
@@ -10,6 +10,7 @@ const props = defineProps<{
taskId: string taskId: string
stepId: string stepId: string
stepNumber: number stepNumber: number
isLastCompletedStep?: boolean
}>() }>()
const taskStore = useTaskStore() const taskStore = useTaskStore()
@@ -67,6 +68,39 @@ const isOffEstimation = computed(() => {
duration: duration.value duration: duration.value
}) })
}) })
// Duration editing
const isEditing = ref(false)
const editedDuration = ref(0)
const durationInputRef = ref<HTMLInputElement | null>(null)
const isEditable = computed(
() => props.isLastCompletedStep && record.value && !record.value.end
)
function startEditing() {
editedDuration.value = duration.value ?? 0
isEditing.value = true
nextTick(() => {
durationInputRef.value?.focus()
durationInputRef.value?.select()
})
}
function confirmEdit() {
if (editedDuration.value >= 1) {
recordStore.updateStepDuration({
taskId: props.taskId,
stepId: props.stepId,
newDurationMinutes: editedDuration.value
})
}
isEditing.value = false
}
function cancelEdit() {
isEditing.value = false
}
</script> </script>
<template> <template>
@@ -96,7 +130,28 @@ const isOffEstimation = computed(() => {
{{ step.title }} {{ step.title }}
</td> </td>
<td class="estimation minutes">{{ step.estimation }} min</td> <td class="estimation minutes">{{ step.estimation }} min</td>
<td class="minutes" v-if="stepRecord">{{ duration }} min</td> <td class="minutes" v-if="stepRecord">
<template v-if="isEditing">
<input
ref="durationInputRef"
type="number"
min="1"
v-model.number="editedDuration"
@blur="confirmEdit"
@keydown.enter="confirmEdit"
@keydown.escape="cancelEdit"
class="input is-small duration-input"
/>
min
</template>
<span
v-else
:class="{ 'is-editable': isEditable }"
@click="isEditable && startEditing()"
>
{{ duration }} min
</span>
</td>
<td v-else></td> <td v-else></td>
</tr> </tr>
</template> </template>
@@ -145,6 +200,19 @@ $blob-color: $link;
.minutes { .minutes {
text-align: right; text-align: right;
} }
.is-editable {
cursor: pointer;
text-decoration: underline dotted;
&:hover {
opacity: 0.7;
}
}
.duration-input {
width: 60px;
display: inline-block;
}
} }
@keyframes pulse { @keyframes pulse {

View File

@@ -32,6 +32,16 @@ useAppTitle(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 lastCompletedStepId = computed(() => {
if (!record.value || !task.value || !record.value.currentStepId) return null
const currentIndex = task.value.steps.findIndex(
(s) => s.id === record.value?.currentStepId
)
return currentIndex > 0 ? task.value.steps[currentIndex - 1].id : null
})
</script> </script>
<template> <template>
@@ -65,6 +75,7 @@ const recordNotes = computed(() => recordStore.getRecordNotes(props.taskId))
:key="step.id" :key="step.id"
:step-id="step.id" :step-id="step.id"
:step-number="key + 1" :step-number="key + 1"
:is-last-completed-step="step.id === lastCompletedStepId"
/> />
</tbody> </tbody>
</table> </table>

View File

@@ -5,6 +5,7 @@ import type { Recordable } from '../interfaces/recordable'
import type { TimeRange } from '../interfaces/time-range' import type { TimeRange } from '../interfaces/time-range'
import { TaskRecord } from '../models/task-record' import { TaskRecord } from '../models/task-record'
import { addBreakTimeToStepRecords } from '../services/breaktime-service' import { addBreakTimeToStepRecords } from '../services/breaktime-service'
import { isTimeSpeedUp } from '@/shared/format-date'
export interface TaskRecordStoreState { export interface TaskRecordStoreState {
records: { [recordId: string]: Recordable } records: { [recordId: string]: Recordable }
@@ -221,6 +222,51 @@ export const useTaskRecordStore = defineStore('task-record-store', {
start: toISODate(new Date(latestStartDate)) start: toISODate(new Date(latestStartDate))
}) })
} }
},
updateStepDuration(params: {
taskId: string
stepId: string
newDurationMinutes: number
}) {
const record = this.records[params.taskId]
if (!record) return
const stepRecord = record.stepRecords[params.stepId]
if (!stepRecord?.end) return // Only completed steps
// Calculate new end time (in dev mode, units are seconds for faster testing)
const unitMultiplier = isTimeSpeedUp() ? 1 : 60
const startMs = new Date(stepRecord.start).getTime()
const newEndMs =
startMs + params.newDurationMinutes * unitMultiplier * 1000
const newEnd = toISODate(new Date(newEndMs))
// Build updated step records
const updatedStepRecords = { ...record.stepRecords }
updatedStepRecords[params.stepId] = {
...stepRecord,
end: newEnd
}
// Adjust current step's start time for continuity, but only if new end is in the past
const currentStepId = record.currentStepId
const isNewEndInPast = newEndMs <= Date.now()
if (currentStepId && updatedStepRecords[currentStepId] && isNewEndInPast) {
updatedStepRecords[currentStepId] = {
...updatedStepRecords[currentStepId],
start: newEnd
}
}
this.$patch({
records: {
...this.records,
[params.taskId]: {
...record,
stepRecords: updatedStepRecords
}
}
})
} }
}, },
getters: { getters: {

View File

@@ -1,10 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { createUuid } from '@/shared/create-uuid' import { createUuid } from '@/shared/create-uuid'
import type { Stepable } from '../interfaces/stepable'
import TaskForm from './TaskForm.vue' import TaskForm from './TaskForm.vue'
const props = defineProps<{
initialSteps?: Stepable[]
initialTitle?: string
}>()
const id = createUuid() const id = createUuid()
</script> </script>
<template> <template>
<task-form :id="id" :action="'new'" /> <task-form :id="id" :initial-steps="props.initialSteps" :initial-title="props.initialTitle" />
</template> </template>

View File

@@ -14,19 +14,23 @@ const router = useRouter()
const props = defineProps<{ const props = defineProps<{
id: string id: string
initialTask?: Taskable initialTask?: Taskable
initialSteps?: Stepable[]
initialTitle?: string
}>() }>()
const id = computed(() => props.id) const id = computed(() => props.id)
const hasTasks = computed(() => store.tasks.length > 0) const hasTasks = computed(() => store.tasks.length > 0)
const steps = ref<Stepable[]>( const steps = ref<Stepable[]>(
props.initialTask props.initialSteps?.length
? Task.fromTaskable(props.initialTask).steps ? props.initialSteps
: hasTasks.value : props.initialTask
? [] ? Task.fromTaskable(props.initialTask).steps
: exampleSteps : hasTasks.value
? []
: exampleSteps
) )
const title = ref(props.initialTask?.title ?? '') const title = ref(props.initialTitle ?? props.initialTask?.title ?? '')
const link = ref(props.initialTask?.link ?? '') const link = ref(props.initialTask?.link ?? '')
const totalEstimation = computed(() => const totalEstimation = computed(() =>

View File

@@ -77,7 +77,9 @@ describe('adapt steps to textarea value', () => {
const [step] = adaptTextareaToSteps(stepInTextarea) const [step] = adaptTextareaToSteps(stepInTextarea)
expect(step.id).toMatchInlineSnapshot(`"66f312736335fce1df9a8b95c7be3fce-1"`) expect(step.id).toMatchInlineSnapshot(
`"66f312736335fce1df9a8b95c7be3fce-1"`
)
}) })
it('creates generated ids based on title and estimation and indexes when duplicated', () => { it('creates generated ids based on title and estimation and indexes when duplicated', () => {
@@ -93,4 +95,167 @@ describe('adapt steps to textarea value', () => {
`"9b237c28d5254f2b819fa66c853a9a60-2"` `"9b237c28d5254f2b819fa66c853a9a60-2"`
) )
}) })
it('parses steps with unchecked checkbox format', () => {
const stepInTextarea = '- [ ] step with checkbox | 20'
const expectedStep = fixtureStep({
id: expect.any(String),
title: 'step with checkbox',
estimation: 20
})
expect(adaptTextareaToSteps(stepInTextarea)).toEqual([expectedStep])
})
it('parses steps with checked checkbox format', () => {
const stepInTextarea = '- [x] completed step | 15'
const expectedStep = fixtureStep({
id: expect.any(String),
title: 'completed step',
estimation: 15
})
expect(adaptTextareaToSteps(stepInTextarea)).toEqual([expectedStep])
})
it('parses checkbox steps without estimation', () => {
const stepInTextarea = '- [ ] step without estimation'
const expectedStep = fixtureStep({
id: expect.any(String),
title: 'step without estimation',
estimation: 0
})
expect(adaptTextareaToSteps(stepInTextarea)).toEqual([expectedStep])
})
describe('subtask support', () => {
it('flattens indented subtasks with parent prefix', () => {
const stepInTextarea = `- Parent task | 5
- Child task 1 | 3
- Child task 2 | 2`
const expectedSteps = [
fixtureStep({
id: expect.any(String),
title: '(Parent task) - Child task 1',
estimation: 3
}),
fixtureStep({
id: expect.any(String),
title: '(Parent task) - Child task 2',
estimation: 2
})
]
expect(adaptTextareaToSteps(stepInTextarea)).toEqual(expectedSteps)
})
it('ignores parent estimation when it has children', () => {
const stepInTextarea = `- Parent | 10
- Child | 3`
const steps = adaptTextareaToSteps(stepInTextarea)
expect(steps).toHaveLength(1)
expect(steps[0].title).toBe('(Parent) - Child')
expect(steps[0].estimation).toBe(3)
})
it('treats standalone items (no children) as regular steps', () => {
const stepInTextarea = `- Standalone task | 4`
const expectedSteps = [
fixtureStep({
id: expect.any(String),
title: 'Standalone task',
estimation: 4
})
]
expect(adaptTextareaToSteps(stepInTextarea)).toEqual(expectedSteps)
})
it('handles mixed subtasks and standalone items', () => {
const stepInTextarea = `- Parent task | 5
- Child task 1 | 3
- Child task 2 | 2
- Standalone | 4`
const expectedSteps = [
fixtureStep({
id: expect.any(String),
title: '(Parent task) - Child task 1',
estimation: 3
}),
fixtureStep({
id: expect.any(String),
title: '(Parent task) - Child task 2',
estimation: 2
}),
fixtureStep({
id: expect.any(String),
title: 'Standalone',
estimation: 4
})
]
expect(adaptTextareaToSteps(stepInTextarea)).toEqual(expectedSteps)
})
it('handles tab indentation', () => {
const stepInTextarea = `- Parent | 5
\t- Child | 3`
const steps = adaptTextareaToSteps(stepInTextarea)
expect(steps).toHaveLength(1)
expect(steps[0].title).toBe('(Parent) - Child')
})
it('treats orphan indented line as regular step', () => {
const stepInTextarea = ` - Orphan indented | 3`
const expectedSteps = [
fixtureStep({
id: expect.any(String),
title: 'Orphan indented',
estimation: 3
})
]
expect(adaptTextareaToSteps(stepInTextarea)).toEqual(expectedSteps)
})
it('handles multiple parents with their children', () => {
const stepInTextarea = `- Parent 1 | 5
- Child 1a | 2
- Child 1b | 3
- Parent 2 | 4
- Child 2a | 1`
const expectedSteps = [
fixtureStep({
id: expect.any(String),
title: '(Parent 1) - Child 1a',
estimation: 2
}),
fixtureStep({
id: expect.any(String),
title: '(Parent 1) - Child 1b',
estimation: 3
}),
fixtureStep({
id: expect.any(String),
title: '(Parent 2) - Child 2a',
estimation: 1
})
]
expect(adaptTextareaToSteps(stepInTextarea)).toEqual(expectedSteps)
})
})
}) })

View File

@@ -4,12 +4,14 @@ import type { Stepable } from '../interfaces/stepable'
export const adaptStepsToTextarea = (steps: Stepable[]) => export const adaptStepsToTextarea = (steps: Stepable[]) =>
steps.map((step) => `- ${step.title} | ${step.estimation}`).join('\n') steps.map((step) => `- ${step.title} | ${step.estimation}`).join('\n')
const isIndented = (line: string): boolean => /^[\t ]+/.test(line)
const extractTitleAndEstimationFromStep = ( const extractTitleAndEstimationFromStep = (
rawStep: string rawStep: string
): [string, number] => { ): [string, number] => {
const [rawTitle, rawEstimation] = rawStep const [rawTitle, rawEstimation] = rawStep
.trim() .trim()
.replace(/^-\s*/, '') .replace(/^-\s*(\[[ x]\]\s*)?/, '')
.split('|') .split('|')
const title = rawTitle.trim() const title = rawTitle.trim()
@@ -23,22 +25,58 @@ const extractTitleAndEstimationFromStep = (
return [title, estimation] return [title, estimation]
} }
export const adaptTextareaToSteps = (textareaValue: string): Stepable[] => export const adaptTextareaToSteps = (textareaValue: string): Stepable[] => {
textareaValue const lines = textareaValue.split('\n')
.split('\n') const result: Array<{ title: string; estimation: number }> = []
.map((rawStep) => {
const [title, estimation] = extractTitleAndEstimationFromStep(rawStep)
if (!title) { let currentParent: { title: string; estimation: number } | null = null
return null let parentHasChildren = false
const flushParent = () => {
if (currentParent && !parentHasChildren) {
result.push(currentParent)
}
currentParent = null
parentHasChildren = false
}
for (const line of lines) {
const [title, estimation] = extractTitleAndEstimationFromStep(line)
if (!title) {
continue
}
if (isIndented(line)) {
// Indented line - flatten with parent prefix if parent exists
if (currentParent && currentParent.title) {
const flattenedTitle = `(${currentParent.title}) - ${title}`
result.push({ title: flattenedTitle, estimation })
parentHasChildren = true
} else {
// Orphan indented line - treat as regular step
result.push({ title, estimation })
} }
} else {
// Non-indented line - potential parent
flushParent()
currentParent = { title, estimation }
}
}
return { id: generateId(`${title}-${estimation}`), title, estimation } // Flush any remaining parent
}) flushParent()
.filter((step): step is Stepable => step !== null)
return result
.map(({ title, estimation }) => ({
id: generateId(`${title}-${estimation}`),
title,
estimation
}))
.map((step, index, steps) => { .map((step, index, steps) => {
const subSteps = steps.slice(0, index + 1) const subSteps = steps.slice(0, index + 1)
const duplicates = subSteps.filter((s) => s.id === step.id).length const duplicates = subSteps.filter((s) => s.id === step.id).length
return { ...step, id: `${step.id}-${duplicates}` } return { ...step, id: `${step.id}-${duplicates}` }
}) })
}

View File

@@ -1,6 +1,6 @@
import type { ISODate } from './types/date' import type { ISODate } from './types/date'
const isTimeSpeedUp = () => process.env.NODE_ENV === 'development' export 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()

View File

@@ -1,15 +1,215 @@
<template> <template>
<div class="about"> <div class="about container">
<h1>This is an about page</h1> <div class="content">
<h1 class="title is-2">About Fail Well</h1>
<section class="section-block">
<h2 class="title is-4">What is Fail Well?</h2>
<p>
Fail Well is a tool designed to help developers and knowledge workers
understand the gap between their expectations and reality. When you
plan a task, you estimate how long each step will take. When you
execute it, you discover how long it actually takes. The difference
between the two is where learning happens.
</p>
<p>
This is not about being perfect. It's about being honest with
yourself, understanding your work better, and continuously improving
your ability to plan and execute.
</p>
</section>
<section class="section-block">
<h2 class="title is-4">Origins: Toyota Production System</h2>
<p>
Fail Well draws inspiration from
<strong
><a
href="https://www.lean.org/lexicon-terms/standardized-work/"
target="_blank"
rel="noopener noreferrer"
>Standardized Work</a
></strong
>, a core principle of the Toyota Production System (<a
href="https://thinking-people-system.house/"
target="_blank"
rel="noopener noreferrer"
>TPS</a
>). In manufacturing, standardized work means documenting the
best-known way to perform a task, including the expected time for each
step.
</p>
<p>
When reality differs from the standard, it reveals an opportunity:
either the process can be improved, or the standard needs updating.
This continuous feedback loop is at the heart of
<strong
><a
href="https://www.lean.org/lexicon-terms/kaizen/"
target="_blank"
rel="noopener noreferrer"
>Kaizen</a
></strong
>.
</p>
<p>
Fail Well brings this philosophy to knowledge work. By tracking your
planned steps and estimated times against actual execution, you create
your own feedback loop for improvement.
</p>
</section>
<section class="section-block">
<h2 class="title is-4">Understanding the Gap</h2>
<p>
We often misjudge how long tasks will take. Sometimes we
underestimate, sometimes we overestimate. The reasons vary:
</p>
<ul>
<li>Unexpected complexity or dependencies</li>
<li>Interruptions and context switching</li>
<li>Overconfidence or unfamiliarity with the problem</li>
<li>Missing prerequisites or unclear requirements</li>
</ul>
<p>
By systematically recording these gaps, patterns emerge. You start to
recognize which types of tasks you consistently misjudge and why. This
self-awareness is the foundation of better planning and execution.
</p>
</section>
<section class="section-block">
<h2 class="title is-4">A Tool for Critical Thinking</h2>
<p>
Fail Well encourages you to reflect on your own work. After completing
a task, you can review the history and ask yourself:
</p>
<ul>
<li>Why did this step take longer than expected?</li>
<li>What assumption was wrong?</li>
<li>What would I do differently next time?</li>
<li>Should I break this step into smaller parts?</li>
</ul>
<p>
This reflective practice transforms routine work into a learning
opportunity. Over time, you develop better intuition for estimating
and a clearer understanding of your own working patterns.
</p>
</section>
<section class="section-block">
<h2 class="title is-4">This is Not Micromanagement</h2>
<p>Let's be clear about what Fail Well is <strong>not</strong>.</p>
<p>
<strong>Micromanagement</strong> is when someone else tracks your
time, judges your performance, and uses that data against you. It
creates pressure, reduces autonomy, and often leads to gaming the
system rather than genuine improvement.
</p>
<p>
<strong>Fail Well is the opposite.</strong> It's a personal tool for
self-reflection. You control the data. You decide what to track and
what to learn from it. There's no external judgment, no performance
review, no one looking over your shoulder.
</p>
<p>
The goal is to understand your work better, make more realistic plans,
and reduce the frustration that comes from consistently missing
estimates.
</p>
<p>
When you track your own work for your own benefit, you're practicing
<strong>self-management</strong>, not submitting to micromanagement.
</p>
<p>
That's why Fail Well is local only, your data is not in the cloud. And
you decide to share records.
</p>
</section>
<section class="section-block">
<h2 class="title is-4">Getting Started</h2>
<p>Using Fail Well is simple:</p>
<ol>
<li>
<strong>Create a task</strong> with a list of steps and time
estimates (in minutes)
</li>
<li>
<strong>Record your execution</strong> by starting the timer and
marking steps as you complete them
</li>
<li>
<strong>Review the results</strong> to see where your estimates were
accurate and where they diverged
</li>
<li>
<strong>Duplicate and iterate</strong> on similar tasks to track
your improvement over time
</li>
</ol>
<p>
Start with small, well-defined tasks. As you get comfortable with the
process, you can apply it to larger projects by breaking them into
smaller pieces.
</p>
</section>
<section class="section-block author-section">
<h2 class="title is-4">Author</h2>
<p>
Fail Well was created by
<a
href="https://juliencalixte.eu"
target="_blank"
rel="noopener noreferrer"
>Julien Calixte</a
>.
</p>
</section>
<div class="back-link">
<router-link :to="{ name: 'home' }" class="button is-light">
Back to Home
</router-link>
</div>
</div>
</div> </div>
</template> </template>
<style> <style scoped>
@media (min-width: 1024px) { .about {
.about { padding: 2rem 1rem;
min-height: 100vh; max-width: 800px;
display: flex; margin: 0 auto;
align-items: center; }
}
.section-block {
margin-bottom: 2rem;
}
.section-block p {
margin-bottom: 1rem;
line-height: 1.7;
}
.section-block ul,
.section-block ol {
margin-bottom: 1rem;
margin-left: 1.5rem;
}
.section-block li {
margin-bottom: 0.5rem;
}
.author-section {
padding-top: 1rem;
border-top: 1px solid #e0e0e0;
}
.back-link {
margin-top: 2rem;
text-align: center;
} }
</style> </style>

View File

@@ -1,9 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import TaskList from '@/modules/task/components/TaskList.vue' import TaskList from '@/modules/task/components/TaskList.vue'
import { adaptTextareaToSteps } from '@/modules/task/infra/adaptStepsToTextarea'
import { useTaskStore } from '@/modules/task/stores/useTask.store' import { useTaskStore } from '@/modules/task/stores/useTask.store'
import { computed } from 'vue' import { computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
const taskStore = useTaskStore() const taskStore = useTaskStore()
const router = useRouter()
const hasTask = computed(() => taskStore.tasks.length > 0) const hasTask = computed(() => taskStore.tasks.length > 0)
@@ -12,20 +15,77 @@ const resetTasks = () => {
taskStore.reset() taskStore.reset()
} }
} }
const extractTitleFromPaste = (
text: string
): { title: string | null; content: string } => {
const match = text.match(/^(#{1,6})\s+(.+?)\n+([\s\S]*)$/)
if (match) {
return { title: match[2].trim(), content: match[3] }
}
return { title: null, content: text }
}
const handlePaste = (event: ClipboardEvent) => {
const clipboardText = event.clipboardData?.getData('text')
if (!clipboardText) {
return
}
const { title, content } = extractTitleFromPaste(clipboardText)
const steps = adaptTextareaToSteps(content)
if (steps.length === 0) {
return
}
router.push({
name: 'new-task',
state: {
initialTitle: title,
initialSteps: JSON.stringify(steps)
}
})
}
onMounted(() => {
document.addEventListener('paste', handlePaste)
})
onUnmounted(() => {
document.removeEventListener('paste', handlePaste)
})
</script> </script>
<template> <template>
<main> <main>
<div class="content-tasks columns is-centered is-vcentered"> <div class="content-tasks columns is-centered is-vcentered">
<div class="new-task-container column buttons"> <div class="new-task-container column">
<router-link :to="{ name: 'new-task' }" class="button is-primary"> <section v-if="!hasTask" class="description-hint">
new task <h1 class="title is-3">Welcome to Fail Well</h1>
</router-link> <p class="subtitle is-6">Track your feedback loops during tasks.</p>
<button v-if="hasTask" class="button is-danger" @click="resetTasks"> <div class="content">
clear the list <p>
</button> Plan your steps with time estimates, then record how long they
actually take. Compare planned vs actual to better understand what
went wrong and what can be improved.
</p>
</div>
</section>
<div class="buttons">
<router-link :to="{ name: 'new-task' }" class="button is-primary">
new task
</router-link>
<button v-if="hasTask" class="button is-danger" @click="resetTasks">
clear the list
</button>
</div>
<section class="message is-info paste-hint">
<div class="message-body">
Paste a list of items to directly create a new task
</div>
</section>
</div> </div>
<task-list class="column task-list" /> <task-list v-if="hasTask" class="column task-list" />
</div> </div>
<!-- <!--
<footer> <footer>
@@ -62,6 +122,20 @@ main {
align-items: center; align-items: center;
} }
.paste-hint {
margin: 1rem;
text-align: center;
}
.description-hint {
margin: 2rem 1rem;
padding: 1.5rem;
text-align: center;
background-color: #f5f5f5;
border-radius: 0.5rem;
max-width: 400px;
}
.task-list { .task-list {
max-height: 60vh; max-height: 60vh;
overflow-y: auto; overflow-y: auto;

View File

@@ -1,9 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import NewTaskForm from '@/modules/task/components/NewTaskForm.vue' import NewTaskForm from '@/modules/task/components/NewTaskForm.vue'
import type { Stepable } from '@/modules/task/interfaces/stepable'
const rawInitialSteps = history.state?.initialSteps as string | undefined
const initialSteps = rawInitialSteps
? (JSON.parse(rawInitialSteps) as Stepable[])
: undefined
const initialTitle = history.state?.initialTitle as string | undefined
</script> </script>
<template> <template>
<div class="new-task"> <div class="new-task">
<NewTaskForm /> <NewTaskForm :initial-steps="initialSteps" :initial-title="initialTitle" />
</div> </div>
</template> </template>