@axium/tasks 0.4.14 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/common.d.ts CHANGED
@@ -20,13 +20,21 @@ export declare const Task: z.ZodObject<{
20
20
  }, z.core.$strip>;
21
21
  export interface Task extends z.infer<typeof Task> {
22
22
  }
23
+ export type TaskTreeStatus = 'completed' | 'pending' | null;
24
+ export interface TaskTreeNode {
25
+ task: Task;
26
+ subtasks: TaskTreeNode[];
27
+ all: TaskTreeStatus;
28
+ completion: number;
29
+ }
30
+ export declare function buildTaskTree(tasks: Task[]): TaskTreeNode[];
23
31
  export declare const TaskListInit: z.ZodObject<{
24
32
  name: z.ZodString;
25
33
  description: z.ZodOptional<z.ZodNullable<z.ZodString>>;
26
34
  }, z.core.$strip>;
27
35
  export type TaskListInit = z.infer<typeof TaskListInit>;
28
36
  export declare const TaskListUpdate: z.ZodObject<{
29
- all_completed: z.ZodOptional<z.ZodBoolean>;
37
+ action: z.ZodLiteral<"mark_all_pending" | "delete_completed">;
30
38
  }, z.core.$strip>;
31
39
  export type TaskListUpdate = z.infer<typeof TaskListUpdate>;
32
40
  export declare const TaskList: z.ZodObject<{
@@ -45,9 +53,39 @@ export declare const TaskList: z.ZodObject<{
45
53
  id: z.ZodUUID;
46
54
  created: z.ZodCoercedDate<unknown>;
47
55
  }, z.core.$strip>>>;
56
+ isShared: z.ZodBoolean;
57
+ acl: z.ZodArray<z.ZodObject<{
58
+ itemId: z.ZodUUID;
59
+ userId: z.ZodOptional<z.ZodNullable<z.ZodUUID>>;
60
+ role: z.ZodOptional<z.ZodNullable<z.ZodString>>;
61
+ tag: z.ZodOptional<z.ZodNullable<z.ZodString>>;
62
+ user: z.ZodOptional<z.ZodNullable<z.ZodObject<{
63
+ id: z.ZodUUID;
64
+ name: z.ZodString;
65
+ email: z.ZodOptional<z.ZodEmail>;
66
+ emailVerified: z.ZodOptional<z.ZodOptional<z.ZodNullable<z.ZodCoercedDate<unknown>>>>;
67
+ preferences: z.ZodOptional<z.ZodLazy<z.ZodObject<{
68
+ debug: z.ZodDefault<z.ZodBoolean>;
69
+ }, z.core.$strip>>>;
70
+ roles: z.ZodArray<z.ZodString>;
71
+ tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
72
+ registeredAt: z.ZodCoercedDate<unknown>;
73
+ isAdmin: z.ZodOptional<z.ZodBoolean>;
74
+ isSuspended: z.ZodOptional<z.ZodBoolean>;
75
+ }, z.core.$strip>>>;
76
+ createdAt: z.ZodCoercedDate<unknown>;
77
+ }, z.core.$catchall<z.ZodBoolean>>>;
48
78
  }, z.core.$strip>;
49
79
  export interface TaskList extends z.infer<typeof TaskList> {
50
80
  }
81
+ export declare const TasksPreferences: z.ZodObject<{
82
+ show_completed_subtasks: z.ZodDefault<z.ZodLiteral<"inline" | "mirror">>;
83
+ }, z.core.$strip>;
84
+ declare module '@axium/core/apps' {
85
+ interface $AppPreferences {
86
+ tasks: typeof TasksPreferences;
87
+ }
88
+ }
51
89
  declare const TasksAPI: {
52
90
  readonly 'users/:id/task_lists': {
53
91
  readonly GET: z.ZodArray<z.ZodObject<{
@@ -66,6 +104,28 @@ declare const TasksAPI: {
66
104
  id: z.ZodUUID;
67
105
  created: z.ZodCoercedDate<unknown>;
68
106
  }, z.core.$strip>>>>;
107
+ isShared: z.ZodBoolean;
108
+ acl: z.ZodArray<z.ZodObject<{
109
+ itemId: z.ZodUUID;
110
+ userId: z.ZodOptional<z.ZodNullable<z.ZodUUID>>;
111
+ role: z.ZodOptional<z.ZodNullable<z.ZodString>>;
112
+ tag: z.ZodOptional<z.ZodNullable<z.ZodString>>;
113
+ user: z.ZodOptional<z.ZodNullable<z.ZodObject<{
114
+ id: z.ZodUUID;
115
+ name: z.ZodString;
116
+ email: z.ZodOptional<z.ZodEmail>;
117
+ emailVerified: z.ZodOptional<z.ZodOptional<z.ZodNullable<z.ZodCoercedDate<unknown>>>>;
118
+ preferences: z.ZodOptional<z.ZodLazy<z.ZodObject<{
119
+ debug: z.ZodDefault<z.ZodBoolean>;
120
+ }, z.core.$strip>>>;
121
+ roles: z.ZodArray<z.ZodString>;
122
+ tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
123
+ registeredAt: z.ZodCoercedDate<unknown>;
124
+ isAdmin: z.ZodOptional<z.ZodBoolean>;
125
+ isSuspended: z.ZodOptional<z.ZodBoolean>;
126
+ }, z.core.$strip>>>;
127
+ createdAt: z.ZodCoercedDate<unknown>;
128
+ }, z.core.$catchall<z.ZodBoolean>>>;
69
129
  }, z.core.$strip>>;
70
130
  readonly PUT: readonly [z.ZodObject<{
71
131
  name: z.ZodString;
@@ -86,6 +146,28 @@ declare const TasksAPI: {
86
146
  id: z.ZodUUID;
87
147
  created: z.ZodCoercedDate<unknown>;
88
148
  }, z.core.$strip>>>;
149
+ isShared: z.ZodBoolean;
150
+ acl: z.ZodArray<z.ZodObject<{
151
+ itemId: z.ZodUUID;
152
+ userId: z.ZodOptional<z.ZodNullable<z.ZodUUID>>;
153
+ role: z.ZodOptional<z.ZodNullable<z.ZodString>>;
154
+ tag: z.ZodOptional<z.ZodNullable<z.ZodString>>;
155
+ user: z.ZodOptional<z.ZodNullable<z.ZodObject<{
156
+ id: z.ZodUUID;
157
+ name: z.ZodString;
158
+ email: z.ZodOptional<z.ZodEmail>;
159
+ emailVerified: z.ZodOptional<z.ZodOptional<z.ZodNullable<z.ZodCoercedDate<unknown>>>>;
160
+ preferences: z.ZodOptional<z.ZodLazy<z.ZodObject<{
161
+ debug: z.ZodDefault<z.ZodBoolean>;
162
+ }, z.core.$strip>>>;
163
+ roles: z.ZodArray<z.ZodString>;
164
+ tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
165
+ registeredAt: z.ZodCoercedDate<unknown>;
166
+ isAdmin: z.ZodOptional<z.ZodBoolean>;
167
+ isSuspended: z.ZodOptional<z.ZodBoolean>;
168
+ }, z.core.$strip>>>;
169
+ createdAt: z.ZodCoercedDate<unknown>;
170
+ }, z.core.$catchall<z.ZodBoolean>>>;
89
171
  }, z.core.$strip>];
90
172
  };
91
173
  readonly 'task_lists/:id': {
@@ -105,6 +187,28 @@ declare const TasksAPI: {
105
187
  id: z.ZodUUID;
106
188
  created: z.ZodCoercedDate<unknown>;
107
189
  }, z.core.$strip>>>>;
190
+ isShared: z.ZodBoolean;
191
+ acl: z.ZodArray<z.ZodObject<{
192
+ itemId: z.ZodUUID;
193
+ userId: z.ZodOptional<z.ZodNullable<z.ZodUUID>>;
194
+ role: z.ZodOptional<z.ZodNullable<z.ZodString>>;
195
+ tag: z.ZodOptional<z.ZodNullable<z.ZodString>>;
196
+ user: z.ZodOptional<z.ZodNullable<z.ZodObject<{
197
+ id: z.ZodUUID;
198
+ name: z.ZodString;
199
+ email: z.ZodOptional<z.ZodEmail>;
200
+ emailVerified: z.ZodOptional<z.ZodOptional<z.ZodNullable<z.ZodCoercedDate<unknown>>>>;
201
+ preferences: z.ZodOptional<z.ZodLazy<z.ZodObject<{
202
+ debug: z.ZodDefault<z.ZodBoolean>;
203
+ }, z.core.$strip>>>;
204
+ roles: z.ZodArray<z.ZodString>;
205
+ tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
206
+ registeredAt: z.ZodCoercedDate<unknown>;
207
+ isAdmin: z.ZodOptional<z.ZodBoolean>;
208
+ isSuspended: z.ZodOptional<z.ZodBoolean>;
209
+ }, z.core.$strip>>>;
210
+ createdAt: z.ZodCoercedDate<unknown>;
211
+ }, z.core.$catchall<z.ZodBoolean>>>;
108
212
  }, z.core.$strip>;
109
213
  readonly PATCH: readonly [z.ZodObject<{
110
214
  name: z.ZodString;
@@ -125,9 +229,31 @@ declare const TasksAPI: {
125
229
  id: z.ZodUUID;
126
230
  created: z.ZodCoercedDate<unknown>;
127
231
  }, z.core.$strip>>>;
232
+ isShared: z.ZodBoolean;
233
+ acl: z.ZodArray<z.ZodObject<{
234
+ itemId: z.ZodUUID;
235
+ userId: z.ZodOptional<z.ZodNullable<z.ZodUUID>>;
236
+ role: z.ZodOptional<z.ZodNullable<z.ZodString>>;
237
+ tag: z.ZodOptional<z.ZodNullable<z.ZodString>>;
238
+ user: z.ZodOptional<z.ZodNullable<z.ZodObject<{
239
+ id: z.ZodUUID;
240
+ name: z.ZodString;
241
+ email: z.ZodOptional<z.ZodEmail>;
242
+ emailVerified: z.ZodOptional<z.ZodOptional<z.ZodNullable<z.ZodCoercedDate<unknown>>>>;
243
+ preferences: z.ZodOptional<z.ZodLazy<z.ZodObject<{
244
+ debug: z.ZodDefault<z.ZodBoolean>;
245
+ }, z.core.$strip>>>;
246
+ roles: z.ZodArray<z.ZodString>;
247
+ tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
248
+ registeredAt: z.ZodCoercedDate<unknown>;
249
+ isAdmin: z.ZodOptional<z.ZodBoolean>;
250
+ isSuspended: z.ZodOptional<z.ZodBoolean>;
251
+ }, z.core.$strip>>>;
252
+ createdAt: z.ZodCoercedDate<unknown>;
253
+ }, z.core.$catchall<z.ZodBoolean>>>;
128
254
  }, z.core.$strip>];
129
255
  readonly POST: readonly [z.ZodObject<{
130
- all_completed: z.ZodOptional<z.ZodBoolean>;
256
+ action: z.ZodLiteral<"mark_all_pending" | "delete_completed">;
131
257
  }, z.core.$strip>, z.ZodUnknown];
132
258
  readonly PUT: readonly [z.ZodObject<{
133
259
  summary: z.ZodOptional<z.ZodString>;
@@ -161,6 +287,28 @@ declare const TasksAPI: {
161
287
  id: z.ZodUUID;
162
288
  created: z.ZodCoercedDate<unknown>;
163
289
  }, z.core.$strip>>>;
290
+ isShared: z.ZodBoolean;
291
+ acl: z.ZodArray<z.ZodObject<{
292
+ itemId: z.ZodUUID;
293
+ userId: z.ZodOptional<z.ZodNullable<z.ZodUUID>>;
294
+ role: z.ZodOptional<z.ZodNullable<z.ZodString>>;
295
+ tag: z.ZodOptional<z.ZodNullable<z.ZodString>>;
296
+ user: z.ZodOptional<z.ZodNullable<z.ZodObject<{
297
+ id: z.ZodUUID;
298
+ name: z.ZodString;
299
+ email: z.ZodOptional<z.ZodEmail>;
300
+ emailVerified: z.ZodOptional<z.ZodOptional<z.ZodNullable<z.ZodCoercedDate<unknown>>>>;
301
+ preferences: z.ZodOptional<z.ZodLazy<z.ZodObject<{
302
+ debug: z.ZodDefault<z.ZodBoolean>;
303
+ }, z.core.$strip>>>;
304
+ roles: z.ZodArray<z.ZodString>;
305
+ tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
306
+ registeredAt: z.ZodCoercedDate<unknown>;
307
+ isAdmin: z.ZodOptional<z.ZodBoolean>;
308
+ isSuspended: z.ZodOptional<z.ZodBoolean>;
309
+ }, z.core.$strip>>>;
310
+ createdAt: z.ZodCoercedDate<unknown>;
311
+ }, z.core.$catchall<z.ZodBoolean>>>;
164
312
  }, z.core.$strip>;
165
313
  };
166
314
  readonly 'tasks/:id': {
package/dist/common.js CHANGED
@@ -1,4 +1,5 @@
1
- import { $API } from '@axium/core';
1
+ import { $API, AccessControl, appPreferences } from '@axium/core';
2
+ import { zKeys } from '@axium/core/locales';
2
3
  import * as z from 'zod';
3
4
  export const TaskInit = z.object({
4
5
  summary: z.string().max(100).optional(),
@@ -12,19 +13,59 @@ export const Task = TaskInit.extend({
12
13
  id: z.uuid(),
13
14
  created: z.coerce.date(),
14
15
  });
16
+ function computeStats(node, force = false) {
17
+ if (node.all && node.completion !== undefined && !force)
18
+ return;
19
+ let all = node.task.completed ? 'completed' : 'pending', completion = node.subtasks.length ? 0 : node.task.completed ? 1 : 0;
20
+ for (const sub of node.subtasks) {
21
+ computeStats(sub, force);
22
+ if (!sub.all || sub.all !== all)
23
+ all = null;
24
+ completion += sub.completion / node.subtasks.length;
25
+ }
26
+ node.all = all;
27
+ node.completion = completion;
28
+ }
29
+ export function buildTaskTree(tasks) {
30
+ const nodes = new Map();
31
+ const roots = [];
32
+ for (const task of tasks)
33
+ nodes.set(task.id, { task, subtasks: [], all: null, completion: 0 });
34
+ for (const task of tasks) {
35
+ const node = nodes.get(task.id);
36
+ if (task.parentId)
37
+ nodes.get(task.parentId)?.subtasks.push(node);
38
+ else
39
+ roots.push(node);
40
+ }
41
+ for (const node of roots)
42
+ computeStats(node);
43
+ return roots;
44
+ }
15
45
  export const TaskListInit = z.object({
16
46
  name: z.string().min(1).max(50),
17
47
  description: z.string().max(500).nullish(),
18
48
  });
19
49
  export const TaskListUpdate = z.object({
20
- all_completed: z.boolean().optional(),
50
+ action: z.literal(['mark_all_pending', 'delete_completed']),
21
51
  });
22
52
  export const TaskList = TaskListInit.extend({
23
53
  id: z.uuid(),
24
54
  userId: z.uuid(),
25
55
  created: z.coerce.date(),
26
56
  tasks: Task.array().optional(),
57
+ isShared: z.boolean(),
58
+ acl: AccessControl.array(),
27
59
  });
60
+ export const TasksPreferences = z
61
+ .object({
62
+ show_completed_subtasks: z
63
+ .literal(['inline', 'mirror'])
64
+ .default('inline')
65
+ .register(zKeys, { prefix: 'tasks.show_completed_subtasks' }),
66
+ })
67
+ .register(zKeys, { prefix: 'tasks.preferences' });
68
+ appPreferences.set('tasks', TasksPreferences);
28
69
  const TasksAPI = {
29
70
  'users/:id/task_lists': {
30
71
  GET: TaskList.required({ tasks: true }).array(),
package/dist/server.js CHANGED
@@ -1,3 +1,4 @@
1
+ import * as acl from '@axium/server/acl';
1
2
  import { authRequestForItem, checkAuthForUser } from '@axium/server/auth';
2
3
  import { database } from '@axium/server/database';
3
4
  import { parseBody, withError } from '@axium/server/requests';
@@ -9,14 +10,17 @@ addRoute({
9
10
  path: '/api/users/:id/task_lists',
10
11
  params: { id: z.uuid() },
11
12
  async GET(request, { id: userId }) {
12
- await checkAuthForUser(request, userId);
13
+ const { user } = await checkAuthForUser(request, userId);
14
+ const shared = acl.existsIn('task_lists', user, { alias: 'list' });
13
15
  const lists = await database
14
- .selectFrom('task_lists')
15
- .selectAll()
16
- .select(eb => jsonArrayFrom(eb.selectFrom('tasks').selectAll().whereRef('tasks.listId', '=', 'task_lists.id'))
16
+ .selectFrom('task_lists as list')
17
+ .selectAll('list')
18
+ .select(eb => jsonArrayFrom(eb.selectFrom('tasks').selectAll().whereRef('tasks.listId', '=', 'list.id'))
17
19
  .$castTo()
18
20
  .as('tasks'))
19
- .where('userId', '=', userId)
21
+ .select(acl.from('task_lists', { alias: 'list' }))
22
+ .select(eb => shared(eb).$castTo().as('isShared'))
23
+ .where(eb => eb.or([eb('userId', '=', userId), shared(eb)]))
20
24
  .execute()
21
25
  .catch(withError('Could not get task lists'));
22
26
  return lists;
@@ -24,71 +28,79 @@ addRoute({
24
28
  async PUT(request, { id: userId }) {
25
29
  const init = await parseBody(request, TaskListInit);
26
30
  await checkAuthForUser(request, userId);
27
- return await database
31
+ return Object.assign(await database
28
32
  .insertInto('task_lists')
29
33
  .values({ ...init, userId })
30
34
  .returningAll()
31
35
  .executeTakeFirstOrThrow()
32
- .catch(withError('Could not create task list'));
36
+ .catch(withError('Could not create task list')), { isShared: false, acl: [] });
33
37
  },
34
38
  });
35
39
  addRoute({
36
40
  path: '/api/task_lists/:id',
37
41
  params: { id: z.uuid() },
38
42
  async GET(request, { id }) {
39
- const { item } = await authRequestForItem(request, 'task_lists', id, { read: true });
43
+ const { item, fromACL } = await authRequestForItem(request, 'task_lists', id, { read: true });
40
44
  const tasks = await database
41
45
  .selectFrom('tasks')
42
46
  .selectAll()
43
47
  .where('listId', '=', id)
44
48
  .execute()
45
49
  .catch(withError('Could not get tasks'));
46
- return Object.assign(item, { tasks });
50
+ return Object.assign(item, { tasks, isShared: fromACL });
47
51
  },
48
52
  async PUT(request, { id: listId }) {
49
53
  const init = await parseBody(request, TaskInit.omit({ listId: true }));
50
- await authRequestForItem(request, 'task_lists', listId, { edit: true });
51
- return await database
54
+ const { fromACL } = await authRequestForItem(request, 'task_lists', listId, { edit: true });
55
+ return Object.assign(await database
52
56
  .insertInto('tasks')
53
57
  .values({ summary: '', ...init, listId })
54
58
  .returningAll()
55
59
  .executeTakeFirstOrThrow()
56
- .catch(withError('Could not update task list'));
60
+ .catch(withError('Could not update task list')), { isShared: fromACL });
57
61
  },
58
62
  async PATCH(request, { id }) {
59
- await authRequestForItem(request, 'task_lists', id, { edit: true });
60
63
  const init = await parseBody(request, TaskListInit);
61
- return await database
64
+ const { item, fromACL } = await authRequestForItem(request, 'task_lists', id, { edit: true });
65
+ return Object.assign(await database
62
66
  .updateTable('task_lists')
63
67
  .set(init)
64
68
  .where('id', '=', id)
65
69
  .returningAll()
66
70
  .executeTakeFirstOrThrow()
67
- .catch(withError('Could not update task list'));
71
+ .catch(withError('Could not update task list')), { isShared: fromACL, acl: item.acl });
68
72
  },
69
73
  async POST(request, { id }) {
70
74
  const body = await parseBody(request, TaskListUpdate);
71
75
  await authRequestForItem(request, 'task_lists', id, { edit: true });
72
- if (typeof body.all_completed == 'boolean') {
73
- await database
74
- .updateTable('tasks')
75
- .set('completed', body.all_completed)
76
- .where('listId', '=', id)
77
- .where('completed', '=', !body.all_completed)
78
- .returningAll()
79
- .executeTakeFirstOrThrow()
80
- .catch(withError('Could not update task list'));
76
+ switch (body.action) {
77
+ case 'mark_all_pending':
78
+ await database
79
+ .updateTable('tasks')
80
+ .set('completed', false)
81
+ .where('listId', '=', id)
82
+ .executeTakeFirstOrThrow()
83
+ .catch(withError('Could not update task list'));
84
+ break;
85
+ case 'delete_completed':
86
+ await database
87
+ .deleteFrom('tasks')
88
+ .where('listId', '=', id)
89
+ .where('completed', '=', true)
90
+ .executeTakeFirstOrThrow()
91
+ .catch(withError('Could not update task list'));
92
+ break;
81
93
  }
82
94
  return {};
83
95
  },
84
96
  async DELETE(request, { id }) {
85
- await authRequestForItem(request, 'task_lists', id, { manage: true });
86
- return await database
97
+ const { item, fromACL } = await authRequestForItem(request, 'task_lists', id, { manage: true });
98
+ return Object.assign(await database
87
99
  .deleteFrom('task_lists')
88
100
  .where('id', '=', id)
89
101
  .returningAll()
90
102
  .executeTakeFirstOrThrow()
91
- .catch(withError('Could not delete task list'));
103
+ .catch(withError('Could not delete task list')), { isShared: fromACL, acl: item.acl });
92
104
  },
93
105
  });
94
106
  addRoute({
@@ -1,22 +1,33 @@
1
1
  <script lang="ts">
2
2
  import { goto } from '$app/navigation';
3
3
  import { page } from '$app/state';
4
- import { copy } from '@axium/client/gui';
5
- import type { UserPublic } from '@axium/core';
4
+ import { fetchAPI, getAppPreferences, text } from '@axium/client';
6
5
  import { AccessControlDialog, Icon, Popover } from '@axium/client/components';
7
- import { fetchAPI, text } from '@axium/client';
8
- import type { Task, TaskList } from '@axium/tasks/common';
6
+ import { copy } from '@axium/client/gui';
7
+ import { toastStatus } from '@axium/client/toast';
8
+ import { buildTaskTree, type Task, type TaskList, type TaskTreeNode } from '@axium/tasks/common';
9
9
  import type { WithRequired } from 'utilium';
10
10
  import { download } from 'utilium/dom';
11
- import { toastStatus } from '@axium/client/toast';
12
11
 
13
12
  let {
14
13
  list = $bindable(),
15
14
  lists = $bindable(),
16
- user,
17
- }: { list: WithRequired<TaskList, 'tasks'>; lists?: WithRequired<TaskList, 'tasks'>[]; user?: UserPublic } = $props();
15
+ }: {
16
+ list: WithRequired<TaskList, 'tasks'>;
17
+ lists?: WithRequired<TaskList, 'tasks'>[];
18
+ } = $props();
19
+
20
+ const { user } = page.data.session;
21
+
22
+ let tasks = $state(list.tasks),
23
+ open = $state(false);
24
+
25
+ const preferences = await getAppPreferences(user.id, 'tasks');
26
+ const showCompletedInline = preferences.show_completed_subtasks == 'inline';
18
27
 
19
- let tasks = $state(list.tasks);
28
+ const tree = $derived(buildTaskTree(tasks));
29
+
30
+ const completed: TaskTreeNode[] = $derived(tree.filter(node => node.all == 'completed'));
20
31
 
21
32
  function exportTask(task: Task): string {
22
33
  const children = tasks
@@ -31,22 +42,42 @@
31
42
  let acl = $state<HTMLDialogElement>();
32
43
  </script>
33
44
 
34
- {#snippet task_tree(task: Task)}
35
- <div class="task">
36
- <label for="task-completed#{task.id}" style:cursor="pointer">
37
- <Icon i="regular/circle{task.completed ? '-check' : ''}" --size="20px" />
38
- </label>
39
- <input
40
- type="checkbox"
41
- name="completed"
42
- bind:checked={task.completed}
43
- id="task-completed#{task.id}"
44
- style:display="none"
45
- oninput={e => {
46
- task.completed = e.currentTarget.checked;
47
- fetchAPI('PATCH', 'tasks/:id', { completed: task.completed }, task.id);
48
- }}
49
- />
45
+ {#snippet task_checkbox(node: TaskTreeNode, isMirror: boolean = false)}
46
+ {@const task = node.task}
47
+ {@const directCompletion = node.subtasks.length ? node.subtasks.filter(s => s.task.completed).length / node.subtasks.length : 0}
48
+
49
+ <span
50
+ class="task-checkbox"
51
+ class:has-progress={node.subtasks.length && node.completion}
52
+ class:mirror-marker={isMirror}
53
+ style:--completion="{node.completion * 100}%"
54
+ style:--direct-completion="{directCompletion * 100}%"
55
+ >
56
+ {#if isMirror}
57
+ <Icon i="regular/circle{task.completed ? '-check' : ''}" --size="1.25em" />
58
+ {:else}
59
+ <label for="task-completed#{task.id}" style:cursor="pointer" style:display="contents">
60
+ <Icon i="regular/circle{task.completed ? '-check' : ''}" --size="1.25em" />
61
+ </label>
62
+ <input
63
+ type="checkbox"
64
+ name="completed"
65
+ bind:checked={task.completed}
66
+ id="task-completed#{task.id}"
67
+ style:display="none"
68
+ oninput={e => {
69
+ task.completed = e.currentTarget.checked;
70
+ fetchAPI('PATCH', 'tasks/:id', { completed: task.completed }, task.id);
71
+ }}
72
+ />
73
+ {/if}
74
+ </span>
75
+ {/snippet}
76
+
77
+ {#snippet task_tree(node: TaskTreeNode, depth: number = 0, isCompletedMirror: boolean = false)}
78
+ {@const task = node.task}
79
+ <div class="task" style:margin-left="{depth * 1.75}em">
80
+ {@render task_checkbox(node, !showCompletedInline && isCompletedMirror != task.completed)}
50
81
  <input
51
82
  type="text"
52
83
  name="summary"
@@ -58,6 +89,15 @@
58
89
  }}
59
90
  />
60
91
  <Popover showToggle="hover">
92
+ <div
93
+ class="menu-item"
94
+ onclick={() => {
95
+ fetchAPI('PUT', 'task_lists/:id', { summary: '', parentId: task.id }, list.id).then(t => tasks.push(t));
96
+ }}
97
+ >
98
+ <Icon i="arrow-turn-down-right" />
99
+ <span>{text('tasks.add_subtask')}</span>
100
+ </div>
61
101
  <div
62
102
  class="menu-item"
63
103
  onclick={() =>
@@ -71,7 +111,7 @@
71
111
  <Icon i="trash" />
72
112
  <span>{text('generic.delete')}</span>
73
113
  </div>
74
- {#if page.data.session?.user.preferences.debug}
114
+ {#if user.preferences.debug}
75
115
  <div class="menu-item" onclick={() => copy('text/plain', task.id)}>
76
116
  <Icon i="hashtag" --size="14px" />
77
117
  <span>{text('tasks.copy_id')}</span>
@@ -79,10 +119,29 @@
79
119
  {/if}
80
120
  </Popover>
81
121
  </div>
82
-
83
- {#each tasks.filter(task => task.parentId == task.id) as task (task.id)}
84
- {@render task_tree(task)}
122
+ {#each node.subtasks as sub (sub.task.id)}
123
+ {#if sub.all != (isCompletedMirror ? 'pending' : 'completed')}
124
+ {@render task_tree(sub, depth + 1, isCompletedMirror)}
125
+ {/if}
85
126
  {/each}
127
+ {@const completedSubs = node.subtasks.filter(sub => sub.all == 'completed')}
128
+ {#if showCompletedInline && completedSubs.length}
129
+ {#snippet inside()}
130
+ {#each completedSubs as sub (sub.task.id)}
131
+ {@render task_tree(sub, depth + 1, isCompletedMirror)}
132
+ {/each}
133
+ {/snippet}
134
+ {#if node.all == 'completed'}
135
+ {@render inside()}
136
+ {:else}
137
+ <details>
138
+ <summary style:margin-left="{depth * 1.75 + 1}em" class="completed-subtasks subtle"
139
+ >{text('tasks.completed_heading', { count: completedSubs.length })}</summary
140
+ >
141
+ {@render inside()}
142
+ </details>
143
+ {/if}
144
+ {/if}
86
145
  {/snippet}
87
146
 
88
147
  <div class="task-list">
@@ -135,31 +194,32 @@
135
194
  <Icon i="user-group" />
136
195
  <span>{text('tasks.share_list')}</span>
137
196
  </div>
138
- {#if tasks.some(t => !t.completed)}
197
+ {#if tasks.some(t => t.completed)}
139
198
  <div
140
199
  class="menu-item"
141
200
  onclick={() => {
142
- for (const task of tasks) task.completed = true;
201
+ for (const task of tasks) task.completed = false;
143
202
  toastStatus(
144
- fetchAPI('POST', 'task_lists/:id', { all_completed: true }, list.id),
145
- text('tasks.toast_all_completed')
203
+ fetchAPI('POST', 'task_lists/:id', { action: 'mark_all_pending' }, list.id),
204
+ text('tasks.toast_all_pending')
146
205
  );
147
206
  }}
148
207
  >
149
- <Icon i="regular/circle-check" />
150
- <span>{text('tasks.mark_all_complete')}</span>
208
+ <Icon i="regular/circle" />
209
+ <span>{text('tasks.mark_all_pending')}</span>
151
210
  </div>
152
- {/if}
153
- {#if tasks.some(t => t.completed)}
154
211
  <div
155
212
  class="menu-item"
156
213
  onclick={() => {
157
- for (const task of tasks) task.completed = false;
158
- toastStatus(fetchAPI('POST', 'task_lists/:id', { all_completed: false }, list.id), text('tasks.toast_all_pending'));
214
+ tasks = tasks.filter(task => !task.completed);
215
+ toastStatus(
216
+ fetchAPI('POST', 'task_lists/:id', { action: 'delete_completed' }, list.id),
217
+ text('tasks.toast_delete_completed')
218
+ );
159
219
  }}
160
220
  >
161
- <Icon i="regular/circle" />
162
- <span>{text('tasks.mark_all_pending')}</span>
221
+ <Icon i="regular/trash-can-check" />
222
+ <span>{text('tasks.delete_completed')}</span>
163
223
  </div>
164
224
  {/if}
165
225
  <div class="menu-item" onclick={() => copy('text/plain', `${location.origin}/tasks/${list.id}`)}>
@@ -167,12 +227,12 @@
167
227
  <span>{text('tasks.copy_link')}</span>
168
228
  </div>
169
229
  {#if lists}
170
- <div class="menu-item" onclick={() => open(`/tasks/${list.id}`)}>
230
+ <div class="menu-item" onclick={() => window.open(`/tasks/${list.id}`)}>
171
231
  <Icon i="arrow-up-right-from-square" />
172
232
  <span>{text('tasks.open_new_tab')}</span>
173
233
  </div>
174
234
  {/if}
175
- {#if page.data.session?.user.preferences.debug}
235
+ {#if user.preferences.debug}
176
236
  <div class="menu-item" onclick={() => copy('text/plain', list.id)}>
177
237
  <Icon i="hashtag" --size="14px" />
178
238
  <span>{text('tasks.copy_id')}</span>
@@ -187,18 +247,31 @@
187
247
  <span>{text('tasks.new_task')}</span>
188
248
  </button>
189
249
  </div>
190
- <h4>{text('tasks.pending_heading')}</h4>
191
- {#each tasks.filter(task => !task.parentId && !task.completed) as task (task.id)}
192
- {@render task_tree(task)}
250
+
251
+ {#each tree.filter(node => node.all != 'completed') as node (node.task.id)}
252
+ {@render task_tree(node)}
193
253
  {:else}
194
254
  <i class="subtle">{text('tasks.pending_empty')}</i>
195
255
  {/each}
196
- <h4>{text('tasks.completed_heading')}</h4>
197
- {#each tasks.filter(task => !task.parentId && task.completed) as task (task.id)}
198
- {@render task_tree(task)}
199
- {:else}
200
- <i class="subtle">{text('tasks.completed_empty')}</i>
201
- {/each}
256
+
257
+ {#if completed.length || (!showCompletedInline && tasks.some(t => t.completed))}
258
+ <details bind:open>
259
+ <summary
260
+ >{text('tasks.completed_heading', {
261
+ count: (showCompletedInline ? completed : tasks.filter(t => t.completed)).length,
262
+ })}</summary
263
+ >
264
+ {#if showCompletedInline}
265
+ {#each completed as node (node.task.id)}
266
+ {@render task_tree(node)}
267
+ {/each}
268
+ {:else}
269
+ {#each tree.filter(node => node.all != 'pending') as node (node.task.id)}
270
+ {@render task_tree(node, 0, true)}
271
+ {/each}
272
+ {/if}
273
+ </details>
274
+ {/if}
202
275
  </div>
203
276
 
204
277
  <style>
@@ -209,12 +282,39 @@
209
282
 
210
283
  .task {
211
284
  display: grid;
212
- grid-template-columns: 1em 1fr 2em;
285
+ grid-template-columns: 1.25em 1fr 2em;
213
286
  align-items: center;
214
- gap: 1em;
287
+ gap: 0.5em;
288
+
289
+ .task-checkbox {
290
+ display: flex;
291
+ align-items: center;
292
+ justify-content: center;
293
+ position: relative;
294
+ border-radius: 50%;
295
+
296
+ &.mirror-marker {
297
+ opacity: 69%;
298
+ }
299
+
300
+ &.has-progress::before {
301
+ content: '';
302
+ position: absolute;
303
+ inset: 2px;
304
+ border-radius: 50%;
305
+ background: conic-gradient(
306
+ var(--fg-strong) 0% var(--completion),
307
+ var(--fg-accent) 0% var(--direct-completion),
308
+ transparent 0% 100%
309
+ );
310
+ opacity: 0.65;
311
+ z-index: 0;
312
+ pointer-events: none;
313
+ }
314
+ }
215
315
 
216
- .task {
217
- padding-left: 1em;
316
+ input {
317
+ padding: 0.125em 0.25em;
218
318
  }
219
319
  }
220
320
 
@@ -239,4 +339,12 @@
239
339
  padding: 0;
240
340
  }
241
341
  }
342
+
343
+ summary {
344
+ margin-bottom: 0.5em;
345
+ }
346
+
347
+ summary:not(.completed-subtasks) {
348
+ font-weight: bold;
349
+ }
242
350
  </style>
package/locales/en.json CHANGED
@@ -11,21 +11,27 @@
11
11
  "name": "Name",
12
12
  "description": "Description <span class='subtle'>(optional)</span>"
13
13
  },
14
+ "add_subtask": "Add Subtask",
14
15
  "copy_id": "Copy ID",
15
16
  "copy_link": "Copy Link",
16
17
  "export_list": "Export",
17
18
  "share_list": "Share",
18
- "mark_all_complete": "Complete All",
19
- "mark_all_pending": "Un-complete All",
19
+ "mark_all_pending": "Un-complete all",
20
+ "delete_completed": "Delete completed",
20
21
  "open_new_tab": "Open in New Tab",
21
22
  "new_task": "Add Task",
22
- "pending_heading": "Pending",
23
23
  "pending_empty": "No pending tasks.",
24
- "completed_heading": "Completed",
25
- "completed_empty": "No completed tasks.",
26
- "toast_all_completed": "All tasks marked as complete",
24
+ "completed_heading": "Completed ({count})",
27
25
  "toast_all_pending": "All tasks marked as pending",
28
26
  "toast_task_deleted": "Task deleted",
29
- "toast_list_deleted": "Task list deleted"
27
+ "toast_list_deleted": "Task list deleted",
28
+ "toast_delete_completed": "Removed completed tasks",
29
+ "preferences": {
30
+ "show_completed_subtasks": "Show completed subtasks"
31
+ },
32
+ "show_completed_subtasks": {
33
+ "inline": "Inline with pending",
34
+ "mirror": "In completed section"
35
+ }
30
36
  }
31
37
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axium/tasks",
3
- "version": "0.4.14",
3
+ "version": "0.5.1",
4
4
  "author": "James Prevett <axium@jamespre.dev>",
5
5
  "description": "Tasks for Axium",
6
6
  "funding": {
@@ -36,7 +36,7 @@
36
36
  "build": "tsc"
37
37
  },
38
38
  "peerDependencies": {
39
- "@axium/client": ">=0.20.0",
39
+ "@axium/client": ">=0.27.0",
40
40
  "@axium/core": ">=0.23.0",
41
41
  "@axium/server": ">=0.35.0",
42
42
  "@sveltejs/kit": "^2.27.3",
@@ -1,7 +1,9 @@
1
1
  import { getCurrentSession } from '@axium/client/user';
2
2
  import type { Session, UserPublic } from '@axium/core';
3
3
 
4
- export async function load({ parent, url }) {
4
+ export const ssr = false;
5
+
6
+ export async function load({ parent }) {
5
7
  let { session }: { session?: (Session & { user: UserPublic }) | null } = await parent();
6
8
 
7
9
  session ||= await getCurrentSession().catch(() => null);
@@ -1,7 +1,7 @@
1
1
  <script lang="ts">
2
- import { FormDialog, Icon } from '@axium/client/components';
2
+ import { FormDialog, Icon, AppPreferencesDialog } from '@axium/client/components';
3
3
  import { fetchAPI, text } from '@axium/client';
4
- import { TaskListInit } from '@axium/tasks/common';
4
+ import { TaskListInit, TasksPreferences } from '@axium/tasks/common';
5
5
  import { TaskList } from '@axium/tasks/components';
6
6
 
7
7
  const { data } = $props();
@@ -17,14 +17,18 @@
17
17
  <div id="tasks-main">
18
18
  <h1>{text('app_name.tasks')}</h1>
19
19
 
20
- <button id="create-task-list" class="icon-text mobile-float-right" onclick={() => dialog!.showModal()}>
21
- <Icon i="plus" />
22
- <span>{text('tasks.new_list')}</span>
23
- </button>
20
+ <div>
21
+ <AppPreferencesDialog appId="tasks" userId={data.session.userId} schema={TasksPreferences} />
22
+
23
+ <button id="create-task-list" class="icon-text mobile-float-right" onclick={() => dialog!.showModal()}>
24
+ <Icon i="plus" />
25
+ <span>{text('tasks.new_list')}</span>
26
+ </button>
27
+ </div>
24
28
 
25
29
  <div class="lists-container">
26
30
  {#each lists as list}
27
- <TaskList {list} bind:lists user={data.session?.user} />
31
+ <TaskList {list} bind:lists />
28
32
  {/each}
29
33
  </div>
30
34
  </div>
@@ -71,6 +75,7 @@
71
75
  @media (width < 700px) {
72
76
  #tasks-main {
73
77
  padding: 1em;
78
+ padding-bottom: 5em;
74
79
  align-content: center;
75
80
  }
76
81
 
@@ -10,5 +10,5 @@ export async function load({ parent }) {
10
10
 
11
11
  const lists = await fetchAPI('GET', 'users/:id/task_lists', {}, session.userId);
12
12
 
13
- return { lists };
13
+ return { session, lists };
14
14
  }