Compare commits

...

17 Commits

Author SHA1 Message Date
Julien Calixte
25afabaf3d fix: add missing apiUrl to OpenPanel config for self-hosted instance 2026-04-04 14:23:24 +02:00
Julien Calixte
baaa62afdf style: align paste hint text and button vertically 2026-04-04 14:08:55 +02:00
Julien Calixte
a9a8233aee chore: update browserlist 2026-04-04 14:07:50 +02:00
Julien Calixte
967018da12 chore: upgrade vite to 8 and related tooling
- vite 5 → 8
- vite-plugin-pwa 0.19 → 1.2 (vite 8 compat confirmed via vite-pwa/vite-plugin-pwa#918)
- @vitejs/plugin-vue 5 → 6
- vitest 1 → 4
- @vitest/browser, @vitest/ui 1 → 4
- workbox-build, workbox-window added as direct deps at ^7.4.0 to satisfy vite-plugin-pwa peer reqs
2026-04-04 14:06:01 +02:00
Julien Calixte
2a0c2fdbb8 chore: fix sass deprecation warnings and upgrade vite to 5.4
- Upgrade vite from 5.1.3 to 5.4.21 to support modern sass API option
- Configure sass preprocessor with api: 'modern' to silence legacy-js-api warning
- Silence bulma-sourced deprecation warnings (import, global-builtin, color-functions, if-function)
2026-04-04 13:53:59 +02:00
Julien Calixte
d976ca811c feat: add OpenPanel analytics via @openpanel/web SDK 2026-04-04 13:31:09 +02:00
Julien Calixte
eb2004f062 chore: add Docker/nginx setup for Coolify deployment
- Add multi-stage Dockerfile (Node 24 + pnpm builder, nginx:alpine server)
- Add nginx.conf with SPA routing and asset caching
- Remove netlify.toml
2026-04-04 13:17:09 +02:00
Julien Calixte
684e6eb566 fix: remove import saas 2026-01-26 20:10:55 +01:00
Julien Calixte
bc64500bae feat: paste task from button 2026-01-26 19:54:57 +01:00
Julien Calixte
84bf48d866 chore: change package name 2026-01-26 19:49:54 +01:00
Julien Calixte
6885fd344a Merge branch 'main' of github.com:jcalixte/loopycode 2026-01-26 19:49:47 +01:00
Julien Calixte
311d2f6da6 deps: upgrade pnpm lock file 2026-01-26 19:49:25 +01:00
Julien Calixte
1a6befe747 feat: add comprehensive About page
Explain Fail Well's purpose, its origins from Toyota Production System's
standardized work, and how it differs from micromanagement. Include
author attribution and footer navigation link.
2026-01-24 15:22:44 +01:00
Julien Calixte
118d626507 feat: change wording for pause and resume 2026-01-24 14:48:29 +01:00
Julien Calixte
75ea42c6e8 fix: handle edge cases in duration editing
- Use $patch for proper Pinia reactivity when updating step duration
- Only adjust current step's start time if new end is in the past

Fixes #8
2026-01-24 14:37:36 +01:00
Julien Calixte
67d65b54f4 feat: edit mode 2026-01-24 14:31:40 +01:00
Julien Calixte
b2068865e8 feat: display description hint on empty state
Show a welcome message explaining Fail Well's purpose when no tasks exist.
This helps new users understand the app's workflow.

Fixes #9
2026-01-24 14:08:28 +01:00
17 changed files with 2191 additions and 1298 deletions

17
Dockerfile Normal file
View File

@@ -0,0 +1,17 @@
FROM node:24-alpine AS builder
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build-only
FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80

View File

@@ -1,4 +0,0 @@
[[redirects]]
from = "/*"
to = "/"
status = 200

23
nginx.conf Normal file
View File

@@ -0,0 +1,23 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
gzip_min_length 1024;
location / {
try_files $uri $uri/ /index.html;
}
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|webmanifest)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
location = /index.html {
add_header Cache-Control "no-cache";
}
}

View File

@@ -16,6 +16,7 @@
"commit": "cz" "commit": "cz"
}, },
"dependencies": { "dependencies": {
"@openpanel/web": "^1.3.0",
"@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",
@@ -30,16 +31,16 @@
"vue-router": "^4.6.4" "vue-router": "^4.6.4"
}, },
"devDependencies": { "devDependencies": {
"@types/md5": "^2.3.6",
"@faker-js/faker": "^10.2.0", "@faker-js/faker": "^10.2.0",
"@pinia/testing": "^0.1.3", "@pinia/testing": "^0.1.3",
"@rushstack/eslint-patch": "^1.15.0", "@rushstack/eslint-patch": "^1.15.0",
"@tsconfig/node18": "^18.2.6", "@tsconfig/node18": "^18.2.6",
"@types/jsdom": "^27.0.0", "@types/jsdom": "^27.0.0",
"@types/md5": "^2.3.6",
"@types/node": "^25.0.10", "@types/node": "^25.0.10",
"@vitejs/plugin-vue": "^5.0.4", "@vitejs/plugin-vue": "^6.0.5",
"@vitest/browser": "^1.3.0", "@vitest/browser": "^4.1.2",
"@vitest/ui": "^1.3.0", "@vitest/ui": "^4.1.2",
"@vue/eslint-config-prettier": "^9.0.0", "@vue/eslint-config-prettier": "^9.0.0",
"@vue/eslint-config-typescript": "^12.0.0", "@vue/eslint-config-typescript": "^12.0.0",
"@vue/test-utils": "^2.4.4", "@vue/test-utils": "^2.4.4",
@@ -52,10 +53,12 @@
"prettier": "^3.8.1", "prettier": "^3.8.1",
"sass": "^1.97.3", "sass": "^1.97.3",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"vite": "^5.1.3", "vite": "^8.0.3",
"vite-plugin-pwa": "^0.19.0", "vite-plugin-pwa": "^1.2.0",
"vitest": "^1.3.0", "vitest": "^4.1.2",
"vue-tsc": "^3.2.3", "vue-tsc": "^3.2.3",
"webdriverio": "^8.32.2" "webdriverio": "^8.32.2",
"workbox-build": "^7.4.0",
"workbox-window": "^7.4.0"
} }
} }

2996
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

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

@@ -1,7 +1,8 @@
@charset "utf-8"; @charset "utf-8";
@use "variables";
@use '../../node_modules/bulma/bulma.sass';
@import url('https://fonts.googleapis.com/css2?family=Oxygen+Mono&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Oxygen+Mono&display=swap');
@import "variables";
@import '../../node_modules/bulma/bulma.sass';
*, *,
*::before, *::before,

View File

@@ -5,6 +5,7 @@ import App from './App.vue'
import { router } from './router' import { router } from './router'
import VueDiff from 'vue-diff' import VueDiff from 'vue-diff'
import './openpanel'
import './assets/main.scss' import './assets/main.scss'
import 'vue-diff/dist/index.css' import 'vue-diff/dist/index.css'

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,14 +130,41 @@ 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>
<style scoped lang="scss"> <style scoped lang="scss">
@import '@/assets/variables'; $family-monospace: 'Oxygen Mono', monospace;
$family-primary: $family-monospace;
$primary: #192a56;
$info: #40739e;
$link: #2f3640;
$danger: #c23616;
$warning: #fbc531;
$blob-size: 15px; $blob-size: 15px;
$blob-color: $link; $blob-color: $link;
@@ -145,6 +206,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: {

9
src/openpanel.ts Normal file
View File

@@ -0,0 +1,9 @@
import { OpenPanel } from '@openpanel/web'
export const op = new OpenPanel({
apiUrl: 'https://api.panel.apoena.dev',
clientId: '00b205e9-2993-4124-9290-bb45851f5967',
trackScreenViews: true,
trackOutgoingLinks: true,
trackAttributes: true
})

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

@@ -26,8 +26,11 @@ const extractTitleFromPaste = (
return { title: null, content: text } return { title: null, content: text }
} }
const handlePaste = (event: ClipboardEvent) => { const handlePaste = async (event?: ClipboardEvent) => {
const clipboardText = event.clipboardData?.getData('text') const clipboardText = event
? event.clipboardData?.getData('text')
: await navigator.clipboard.readText()
if (!clipboardText) { if (!clipboardText) {
return return
} }
@@ -60,6 +63,17 @@ onUnmounted(() => {
<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"> <div class="new-task-container column">
<section v-if="!hasTask" class="description-hint">
<h1 class="title is-3">Welcome to Fail Well</h1>
<p class="subtitle is-6">Track your feedback loops during tasks.</p>
<div class="content">
<p>
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"> <div class="buttons">
<router-link :to="{ name: 'new-task' }" class="button is-primary"> <router-link :to="{ name: 'new-task' }" class="button is-primary">
new task new task
@@ -70,11 +84,12 @@ onUnmounted(() => {
</div> </div>
<section class="message is-info paste-hint"> <section class="message is-info paste-hint">
<div class="message-body"> <div class="message-body">
Paste a list of items to directly create a new task <span>Paste a list of items to directly create a new task</span>
<button @click="handlePaste()" class="button is-info">Paste</button>
</div> </div>
</section> </section>
</div> </div>
<task-list class="column task-list" /> <task-list v-if="hasTask" class="column task-list" />
</div> </div>
<!-- <!--
<footer> <footer>
@@ -113,7 +128,21 @@ main {
.paste-hint { .paste-hint {
margin: 1rem; margin: 1rem;
}
.paste-hint .message-body {
display: flex;
align-items: center;
gap: 0.75rem;
}
.description-hint {
margin: 2rem 1rem;
padding: 1.5rem;
text-align: center; text-align: center;
background-color: #f5f5f5;
border-radius: 0.5rem;
max-width: 400px;
} }
.task-list { .task-list {

View File

@@ -92,6 +92,18 @@ export default defineConfig({
} }
}) })
], ],
css: {
preprocessorOptions: {
scss: {
api: 'modern',
silenceDeprecations: ['import', 'global-builtin', 'color-functions', 'if-function']
},
sass: {
api: 'modern',
silenceDeprecations: ['import', 'global-builtin', 'color-functions', 'if-function']
}
}
},
resolve: { resolve: {
alias: { alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)) '@': fileURLToPath(new URL('./src', import.meta.url))