Add TodoItem component, enhance task list, and improve API data handling

- Introduced `TodoItem.vue`, a reusable component for task items.
- Refactored `ListScreen` to use `TodoItem` for better modularity.
- Added new animations and styles for smooth transitions.
- Updated `useTasks` with `updateTask` method to sync task updates via API.
- Improved type definitions for `Task` and added nullable fields for flexibility.
- Added dependencies: `luxon`, `@types/luxon`, `uuid`, and `@vueuse/core`.
This commit is contained in:
2026-02-21 21:23:28 +01:00
parent e44e88adfd
commit f1e098d3c7
10 changed files with 192 additions and 39 deletions

View File

@@ -12,7 +12,7 @@ const currentPath = computed(() => router.currentRoute.value.path);
<main class="pb-40 overflow-y-scroll h-screen">
<RouterView />
</main>
<div class="dock dock-xl bg-neutral-400">
<div class="dock dock-xl inset-shadow-sm">
<RouterLink to="/create" :class="currentPath === '/create' ? 'dock-active' : ''">
<PhCheckSquareOffset :size="32" />
</RouterLink>

View File

@@ -0,0 +1,80 @@
<script setup lang="ts">
import { PhCheckSquare, PhDotsThree, PhFlag, PhPause, PhPlay, PhSquare, PhX } from '@phosphor-icons/vue';
import { TaskStatus, Task } from '../types.ts';
import { DateTime } from 'luxon';
import { computed, ref } from 'vue';
import { useTasks } from '../composables/useTasks.ts';
const {updateTask} = useTasks()
const {task} = defineProps<{task: Task}>()
const dueColor = computed(() => {
const dueDiff = task.dueDate ? DateTime.fromMillis(task.dueDate).diffNow('days').days : undefined;
if (!dueDiff) return '';
if (dueDiff < 0) {
return 'text-error'
} else if (dueDiff < 2) {
return 'text-warning';
} else if (dueDiff < 7) {
return 'text-success';
} else {
return 'text-neutral';
}
})
const statusSelectVisible = ref(false);
const handleClick = async(update: Partial<Task>) => {
updateTask({...task, ...update})
statusSelectVisible.value = false;
}
</script>
<template>
<li class="list-row" >
<div class="flex items-center justify-center">
<button class="btn btn-square btn-ghost" @click="statusSelectVisible = !statusSelectVisible">
<PhX :size="20" v-if="statusSelectVisible" />
<template v-else>
<PhSquare v-if="task.status === TaskStatus.WAIT" :size="20" />
<PhCheckSquare v-else-if="task.status === TaskStatus.DONE" :size="20" weight="fill" class="text-success" />
<PhFlag v-else-if="task.status === TaskStatus.FLAG" :size="20" weight="fill" class="text-warning" />
<PhPlay v-else-if="task.status === TaskStatus.WIP" :size="20" weight="fill" class="text-info" />
</template>
</button>
<Transition>
<div v-if="statusSelectVisible" class="">
<template v-if="task.status !== TaskStatus.WIP">
<button v-if="task.status !== TaskStatus.DONE" class="btn btn-square btn-ghost" @click="handleClick({status: TaskStatus.DONE})">
<PhCheckSquare :size="24" weight="regular" class="text-success" />
</button>
<button v-if="task.status !== TaskStatus.WAIT" class="btn btn-square btn-ghost" @click="handleClick({status: TaskStatus.WAIT})">
<PhSquare :size="24" weight="regular" />
</button>
<button v-if="task.status !== TaskStatus.FLAG" class="btn btn-square btn-ghost" @click="handleClick({status: TaskStatus.FLAG})">
<PhFlag :size="24" weight="fill" class="text-warning" />
</button>
<button class="btn btn-square btn-ghost" @click="handleClick({status: TaskStatus.WIP})">
<PhPlay :size="24" weight="fill" class="text-info" />
</button>
</template>
<button v-else class="btn btn-square btn-ghost" @click="handleClick({status: TaskStatus.WAIT})">
<PhPause :size="24" weight="fill" class="text-info" />
</button>
</div>
</Transition>
</div>
<div class="flex flex-col justify-center">
<div>{{task.title}}</div>
<div :class="dueColor" v-if="task.dueDate">{{DateTime.fromMillis(task.dueDate).toFormat('dd/MM/yyyy')}}</div>
</div>
<button class="btn btn-square btn-ghost">
<PhDotsThree :size="24" weight="regular" />
</button>
</li>
</template>
<style scoped>
</style>

View File

@@ -51,10 +51,10 @@ export function useApi() {
const get = (endpoint: string, options: RequestInit = {}) =>
apiFetch(endpoint, { ...options, method: 'GET' });
const post = (endpoint: string, body: BodyInit | null, options: RequestInit = {}) =>
const post = (endpoint: string, body: unknown, options: RequestInit = {}) =>
apiFetch(endpoint, { ...options, method: 'POST', body: JSON.stringify(body) });
const put = (endpoint: string, body: BodyInit | null, options: RequestInit = {}) =>
const put = (endpoint: string, body: unknown, options: RequestInit = {}) =>
apiFetch(endpoint, { ...options, method: 'PUT', body: JSON.stringify(body) });
return {

View File

@@ -27,7 +27,7 @@ export function useSettings() {
let password = readSettings.password ?? '';
if (password) {
try {
password = decrypt(password);
password = decrypt(password) as string;
} catch (error) {
console.warn('Failed to decrypt stored password:', error);
}
@@ -41,7 +41,7 @@ export function useSettings() {
const saveSettings = async () => {
const encryptedPassword = settings.value.password
? encrypt(settings.value.password)
? encrypt(settings.value.password) as string
: '';
await setValue<Settings>('settings', {

View File

@@ -15,8 +15,7 @@ export function useTasks() {
isLoading.value = true;
error.value = null;
try {
const response = await api.get('e5880167-9322-4d7b-8a38-e06bae8a7734/list');
const data = await response.json();
const data = await api.get('e5880167-9322-4d7b-8a38-e06bae8a7734/list').then((res) => res.json());
tasks.value = data.tasks ?? [];
} catch (e: any) {
error.value = e.message || 'Failed to fetch tasks';
@@ -45,15 +44,10 @@ export function useTasks() {
archived: false,
};
await api.put('e5880167-9322-4d7b-8a38-e06bae8a7734/list', { tasks: [newTask] });
// Update local store (optimistic update or refetch)
// Since it's a PUT to '.../list' with {tasks: [task]}, it seems to add/update?
// Based on CreateScreen.vue, it just navigates back.
// To keep store in sync without full refetch, we could add it locally if we knew the full structure
// But maybe it's safer to refetch or at least push to local state if we have the full object.
// Let's refetch to be sure it's in sync with server.
await fetchTasks(true);
const data = await api.put('e5880167-9322-4d7b-8a38-e06bae8a7734/list', { tasks: [newTask] }).then((res) => res.json());
if (data.tasks) {
tasks.value = data.tasks;
}
} catch (e: any) {
error.value = e.message || 'Failed to create task';
console.error('Error creating task:', e);
@@ -63,11 +57,30 @@ export function useTasks() {
}
};
const updateTask = async (task: Task) => {
console.log('updateTask',task);
isLoading.value = true;
error.value = null;
try {
const data = await api.put('e5880167-9322-4d7b-8a38-e06bae8a7734/list', { tasks: [task] }).then((res) => res.json());
if (data.tasks) {
tasks.value = data.tasks;
}
} catch (e: any) {
error.value = e.message || 'Failed to update task';
console.error('Error updating task:', e);
throw e;
} finally {
isLoading.value = false;
}
}
return {
tasks,
isLoading,
error,
fetchTasks,
createTask,
updateTask,
};
}

View File

@@ -1,8 +1,10 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useTasks } from '../composables/useTasks.ts';
import { TaskStatus } from '../types.ts';
import { PhCaretDown, PhCaretUp, PhCheckSquare, PhDotsThree, PhPlay, PhSquare } from '@phosphor-icons/vue';
import { Task } from '../types.ts';
import { PhCaretDown, PhCaretUp } from '@phosphor-icons/vue';
import TodoItem from '../components/TodoItem.vue';
const { tasks, fetchTasks } = useTasks();
@@ -10,7 +12,7 @@ onMounted(async () => {
await fetchTasks();
})
const visibleTasks = computed(() => tasks.value.filter(task => !task.archived))
const visibleTasks = computed<Task[]>(() => tasks.value.filter(task => !task.archived))
const categorizedTasks = computed(() => visibleTasks.value.reduce((acc, task) => {
const tag = task.tag ?? 'Uncategorized';
@@ -39,23 +41,7 @@ const collapsed = ref<string[]>([]);
</div>
<Transition name="fade">
<ul v-if="!collapsed.includes(category)" class="list bg-base-100 rounded-box" >
<li class="list-row" v-for="task in tasks" :key="task.id">
<div class="flex items-center justify-center">
<PhSquare v-if="task.status === TaskStatus.WAIT" :size="20" />
<PhCheckSquare v-else-if="task.status === TaskStatus.DONE" :size="20" weight="fill" class="text-success" />
<PhSquare v-else-if="task.status === TaskStatus.FLAG" :size="20" weight="fill" class="text-warning" />
<PhPlay v-else-if="task.status === TaskStatus.WIP" :size="20" weight="fill" class="text-info" />
</div>
<div class="flex items-center gap-2">
<div>{{task.title}}</div>
</div>
<button class="btn btn-square btn-ghost btn-sm">
<PhDotsThree :size="24" weight="regular" />
</button>
</li>
<TodoItem v-for="task in tasks.sort((a, b) => a.id_ - b.id_)" :key="task.id" :task />
</ul>
</Transition>
</div>

View File

@@ -43,8 +43,16 @@
.fade-leave-active {
transition: opacity 0.25s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.xExpand-enter-active,
.xExpand-leave-active {
transition: all 0.5s ease-in-out;
}
.xExpand-enter-from,
.xExpand-leave-to {
transform: scaleX(0);
}

View File

@@ -1,3 +1,4 @@
export enum TaskStatus {
NONE,
DONE,
@@ -6,6 +7,7 @@ export enum TaskStatus {
FLAG,
}
export type Worklog = {
start: number;
end: number;
@@ -17,9 +19,9 @@ export type Task = {
"tag": string,
"title": string,
"status": TaskStatus,
"lastaction": number,
"lastaction": number | null,
"logs": Worklog[],
"dueDate": number,
"dueDate": number | null,
"id_": number,
"id": number
}