@heyramzi/tasks 0.0.0-stage → 0.4.0
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/LICENSE +21 -0
- package/README.md +285 -2
- package/package.json +98 -3
- package/src/ambient.d.ts +6 -0
- package/src/board-order.ts +137 -0
- package/src/dates.ts +31 -0
- package/src/define-statuses.ts +32 -0
- package/src/demo.svelte +394 -0
- package/src/icons/board-icon.svelte +11 -0
- package/src/icons/chevron-down.svg +1 -0
- package/src/icons/chevron-right.svg +1 -0
- package/src/icons/clock.svg +1 -0
- package/src/icons/close.svg +1 -0
- package/src/icons/gantt-icon.svelte +11 -0
- package/src/icons/list-icon.svelte +16 -0
- package/src/icons/milestone-flag.svg +1 -0
- package/src/icons/play.svg +1 -0
- package/src/icons/spinner.svg +1 -0
- package/src/inbox/icons/call.svg +1 -0
- package/src/inbox/icons/email.svg +1 -0
- package/src/inbox/icons/form.svg +1 -0
- package/src/inbox/icons/slack.svg +1 -0
- package/src/inbox/inbox-card.svelte +279 -0
- package/src/inbox/inbox-draft.svelte +84 -0
- package/src/inbox/inbox-logic.ts +56 -0
- package/src/inbox/inbox-statuses.ts +50 -0
- package/src/inbox/inbox-trace.svelte +44 -0
- package/src/inbox/inbox-types.ts +108 -0
- package/src/inbox/inbox.svelte +203 -0
- package/src/inbox/index.ts +23 -0
- package/src/index.ts +78 -0
- package/src/kanban-board.svelte +560 -0
- package/src/kanban-column.svelte +117 -0
- package/src/kanban-drag.ts +38 -0
- package/src/kanban-types.ts +65 -0
- package/src/lib/cn.ts +11 -0
- package/src/presets/sales.ts +16 -0
- package/src/reorder.ts +20 -0
- package/src/status-badge.svelte +57 -0
- package/src/status-dot.svelte +228 -0
- package/src/status-icon.svelte +30 -0
- package/src/status-types.ts +42 -0
- package/src/task-gantt.svelte +845 -0
- package/src/task-list.svelte +463 -0
- package/src/task-types.ts +43 -0
- package/src/task-view-switch.svelte +118 -0
- package/src/time/duration.ts +69 -0
- package/src/time/time-chip.svelte +62 -0
- package/src/time/time-dot.svelte +58 -0
- package/src/time/time-ring.svelte +74 -0
- package/src/time/time-tracker.svelte +567 -0
- package/src/time/time-types.ts +74 -0
- package/src/tone.ts +54 -0
- package/src/use-kanban-dnd.svelte.ts +173 -0
package/src/demo.svelte
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import KanbanBoard from "./kanban-board.svelte";
|
|
3
|
+
import TaskList from "./task-list.svelte";
|
|
4
|
+
import TaskGantt from "./task-gantt.svelte";
|
|
5
|
+
import TaskViewSwitch from "./task-view-switch.svelte";
|
|
6
|
+
import StatusBadge from "./status-badge.svelte";
|
|
7
|
+
import StatusDot from "./status-dot.svelte";
|
|
8
|
+
import BoardIcon from "./icons/board-icon.svelte";
|
|
9
|
+
import ListIcon from "./icons/list-icon.svelte";
|
|
10
|
+
import GanttIcon from "./icons/gantt-icon.svelte";
|
|
11
|
+
import { defineStatuses } from "./define-statuses.js";
|
|
12
|
+
import { addDays, dayNumber, todayIso } from "./dates.js";
|
|
13
|
+
import { salesStatuses } from "./presets/sales.js";
|
|
14
|
+
import type { KanbanMove } from "./kanban-types.js";
|
|
15
|
+
import type { GanttChange } from "./task-types.js";
|
|
16
|
+
import TimeTracker from "./time/time-tracker.svelte";
|
|
17
|
+
import TimeChip from "./time/time-chip.svelte";
|
|
18
|
+
import type { TimeLogInput, TimeStopInfo } from "./time/time-types.js";
|
|
19
|
+
|
|
20
|
+
type Priority = "low" | "medium" | "high";
|
|
21
|
+
|
|
22
|
+
type Task = {
|
|
23
|
+
id: string;
|
|
24
|
+
status: string;
|
|
25
|
+
title: string;
|
|
26
|
+
assignee: string;
|
|
27
|
+
priority: Priority;
|
|
28
|
+
boardOrder: string;
|
|
29
|
+
start: string;
|
|
30
|
+
end: string;
|
|
31
|
+
dependsOn?: string[];
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const statuses = defineStatuses({
|
|
35
|
+
todo: { label: "To do", tone: "neutral", variant: "open" },
|
|
36
|
+
doing: { label: "In progress", tone: "info", variant: "progress", progress: 0.5 },
|
|
37
|
+
review: { label: "Review", tone: "warning", variant: "progress", progress: 0.75 },
|
|
38
|
+
done: { label: "Done", tone: "success", variant: "completed" },
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const columns = [
|
|
42
|
+
{ id: "todo", label: "To do", hue: "var(--color-muted-foreground)" },
|
|
43
|
+
{ id: "doing", label: "In progress", hue: "var(--color-info)" },
|
|
44
|
+
{ id: "review", label: "Review", hue: "var(--color-warning)" },
|
|
45
|
+
{ id: "done", label: "Done", hue: "var(--color-success)" },
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
const progressByStatus: Record<string, number> = { todo: 0, doing: 0.45, review: 0.8, done: 1 };
|
|
49
|
+
|
|
50
|
+
// Dates hang off today, so the timeline always opens on live work.
|
|
51
|
+
const today = todayIso();
|
|
52
|
+
const on = (days: number) => addDays(today, days);
|
|
53
|
+
|
|
54
|
+
let tasks = $state<Task[]>([
|
|
55
|
+
{ id: "t1", status: "done", title: "Set up analytics", assignee: "Bo", priority: "low", boardOrder: "a0", start: on(-12), end: on(-8) },
|
|
56
|
+
{ id: "t2", status: "done", title: "Interview five customers", assignee: "Cleo", priority: "medium", boardOrder: "a1", start: on(-10), end: on(-4) },
|
|
57
|
+
{ id: "t3", status: "review", title: "Write the pricing page", assignee: "Ada", priority: "high", boardOrder: "a0", start: on(-5), end: on(1), dependsOn: ["t2"] },
|
|
58
|
+
{ id: "t4", status: "doing", title: "Build the onboarding flow", assignee: "Dana", priority: "high", boardOrder: "a0", start: on(-3), end: on(5), dependsOn: ["t1"] },
|
|
59
|
+
{ id: "t5", status: "doing", title: "Draft the launch email", assignee: "Bo", priority: "medium", boardOrder: "a1", start: on(0), end: on(3), dependsOn: ["t3"] },
|
|
60
|
+
{ id: "t6", status: "todo", title: "Record the demo video", assignee: "Cleo", priority: "medium", boardOrder: "a0", start: on(4), end: on(8), dependsOn: ["t4"] },
|
|
61
|
+
{ id: "t7", status: "todo", title: "Pick a shipping date", assignee: "Ada", priority: "low", boardOrder: "a1", start: on(6), end: on(6) },
|
|
62
|
+
{ id: "t8", status: "todo", title: "Launch on Product Hunt", assignee: "Dana", priority: "high", boardOrder: "a2", start: on(10), end: on(11), dependsOn: ["t5", "t6"] },
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
const views = [
|
|
66
|
+
{ id: "board", label: "Board", icon: BoardIcon },
|
|
67
|
+
{ id: "list", label: "List", icon: ListIcon },
|
|
68
|
+
{ id: "gantt", label: "Gantt", icon: GanttIcon },
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
let view = $state("board");
|
|
72
|
+
let sortKey = $state("manual");
|
|
73
|
+
let scale = $state<"days" | "weeks">("days");
|
|
74
|
+
|
|
75
|
+
const sortKeys = [
|
|
76
|
+
{ id: "title", label: "Title", compare: (a: Task, b: Task) => a.title.localeCompare(b.title) },
|
|
77
|
+
{ id: "assignee", label: "Assignee", compare: (a: Task, b: Task) => a.assignee.localeCompare(b.assignee) },
|
|
78
|
+
{ id: "due", label: "Due date", compare: (a: Task, b: Task) => a.end.localeCompare(b.end) },
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
const priorityClass: Record<Priority, string> = {
|
|
82
|
+
low: "bg-muted text-muted-foreground",
|
|
83
|
+
medium: "bg-warning/15 text-warning-700 dark:text-warning-400",
|
|
84
|
+
high: "bg-destructive/15 text-destructive",
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const people: Record<string, string> = {
|
|
88
|
+
Ada: "oklch(0.72 0.12 280)",
|
|
89
|
+
Bo: "oklch(0.74 0.12 160)",
|
|
90
|
+
Cleo: "oklch(0.76 0.12 60)",
|
|
91
|
+
Dana: "oklch(0.72 0.13 20)",
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const dueFormat = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" });
|
|
95
|
+
|
|
96
|
+
function due(task: Task): { label: string; late: boolean } {
|
|
97
|
+
const late = task.status !== "done" && dayNumber(task.end) < dayNumber(today);
|
|
98
|
+
return { label: dueFormat.format(new Date(`${task.end}T00:00:00Z`)), late };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function onMove(id: string, move: KanbanMove) {
|
|
102
|
+
tasks = tasks.map((task) =>
|
|
103
|
+
task.id === id
|
|
104
|
+
? { ...task, status: move.columnId, boardOrder: move.boardOrder ?? task.boardOrder }
|
|
105
|
+
: task,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function onChange(id: string, change: GanttChange) {
|
|
110
|
+
tasks = tasks.map((task) => (task.id === id ? { ...task, ...change } : task));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---- time tracking, an option the host turns on
|
|
114
|
+
type Entry = { id: string; taskId: string; person: string; day: string; minutes: number; note?: string };
|
|
115
|
+
|
|
116
|
+
const estimates: Record<string, number> = { t3: 360, t4: 480, t5: 180, t6: 240 };
|
|
117
|
+
|
|
118
|
+
let tracking = $state(true);
|
|
119
|
+
let timeTaskId = $state("t4");
|
|
120
|
+
let nextEntry = 100;
|
|
121
|
+
let entries = $state<Entry[]>([
|
|
122
|
+
{ id: "e1", taskId: "t4", person: "Dana", day: on(-3), minutes: 150, note: "Signup and email step" },
|
|
123
|
+
{ id: "e2", taskId: "t4", person: "Ada", day: on(-2), minutes: 95, note: "Copy for the welcome screen" },
|
|
124
|
+
{ id: "e3", taskId: "t4", person: "Dana", day: on(-2), minutes: 125, note: "Progress bar and empty states" },
|
|
125
|
+
{ id: "e4", taskId: "t4", person: "Dana", day: on(-1), minutes: 80 },
|
|
126
|
+
{ id: "e5", taskId: "t3", person: "Ada", day: on(-5), minutes: 210, note: "First draft" },
|
|
127
|
+
{ id: "e6", taskId: "t3", person: "Bo", day: on(-3), minutes: 95, note: "Pricing table" },
|
|
128
|
+
{ id: "e7", taskId: "t3", person: "Ada", day: on(-1), minutes: 85, note: "Review changes" },
|
|
129
|
+
{ id: "e8", taskId: "t5", person: "Bo", day: on(0), minutes: 40 },
|
|
130
|
+
{ id: "e9", taskId: "t1", person: "Bo", day: on(-12), minutes: 60 },
|
|
131
|
+
]);
|
|
132
|
+
// The host keeps the running timer. A reload would read it back from storage.
|
|
133
|
+
let timer = $state<{ taskId: string; startedAt: number } | null>({
|
|
134
|
+
taskId: "t4",
|
|
135
|
+
startedAt: Date.now() - (12 * 60 + 41) * 1000,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
let minutesByTask = $derived.by(() => {
|
|
139
|
+
const totals: Record<string, number> = {};
|
|
140
|
+
for (const entry of entries) totals[entry.taskId] = (totals[entry.taskId] ?? 0) + entry.minutes;
|
|
141
|
+
return totals;
|
|
142
|
+
});
|
|
143
|
+
let timeEntries = $derived(entries.filter((entry) => entry.taskId === timeTaskId));
|
|
144
|
+
let timeRunning = $derived(timer?.taskId === timeTaskId ? { startedAt: timer.startedAt } : null);
|
|
145
|
+
|
|
146
|
+
function addEntry(taskId: string, day: string, minutes: number, note?: string) {
|
|
147
|
+
entries = [{ id: `e${nextEntry++}`, taskId, person: "Dana", day, minutes, note }, ...entries];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function onLog(input: TimeLogInput) {
|
|
151
|
+
addEntry(timeTaskId, input.day, input.minutes, input.note);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function onStop(info: TimeStopInfo) {
|
|
155
|
+
if (timer) addEntry(timer.taskId, info.day, info.minutes);
|
|
156
|
+
timer = null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const listGrid = "grid grid-cols-[minmax(0,1fr)_7rem_5.5rem_4.5rem] items-center gap-3 max-sm:grid-cols-[minmax(0,1fr)_4.5rem]";
|
|
160
|
+
</script>
|
|
161
|
+
|
|
162
|
+
{#snippet avatar(name: string)}
|
|
163
|
+
<span
|
|
164
|
+
class="grid size-5 shrink-0 place-items-center rounded-full text-[10px] font-semibold text-white"
|
|
165
|
+
style:background={people[name] ?? "var(--color-muted-foreground)"}
|
|
166
|
+
aria-hidden="true"
|
|
167
|
+
>
|
|
168
|
+
{name[0]}
|
|
169
|
+
</span>
|
|
170
|
+
{/snippet}
|
|
171
|
+
|
|
172
|
+
<div class="flex flex-col gap-6 rounded-xl border border-border bg-background p-4">
|
|
173
|
+
<div class="flex h-[600px] flex-col gap-3">
|
|
174
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
175
|
+
<TaskViewSwitch {views} bind:value={view} label="Task view" />
|
|
176
|
+
<div class="ml-auto flex items-center gap-3">
|
|
177
|
+
<label class="flex cursor-pointer items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
|
178
|
+
<input type="checkbox" bind:checked={tracking} class="size-3.5 accent-[var(--tone-accent,var(--color-primary))]" />
|
|
179
|
+
Time tracking
|
|
180
|
+
</label>
|
|
181
|
+
{#if view === "gantt"}
|
|
182
|
+
<TaskViewSwitch
|
|
183
|
+
views={[
|
|
184
|
+
{ id: "days", label: "Days" },
|
|
185
|
+
{ id: "weeks", label: "Weeks" },
|
|
186
|
+
]}
|
|
187
|
+
bind:value={scale}
|
|
188
|
+
label="Timeline scale"
|
|
189
|
+
/>
|
|
190
|
+
{:else}
|
|
191
|
+
<label for="tasks-sort" class="text-xs font-medium text-muted-foreground">Sort</label>
|
|
192
|
+
<select
|
|
193
|
+
id="tasks-sort"
|
|
194
|
+
bind:value={sortKey}
|
|
195
|
+
class="h-8 rounded-md border border-border bg-background px-2 text-xs text-foreground"
|
|
196
|
+
>
|
|
197
|
+
<option value="manual">Manual</option>
|
|
198
|
+
{#each sortKeys as key (key.id)}
|
|
199
|
+
<option value={key.id}>{key.label}</option>
|
|
200
|
+
{/each}
|
|
201
|
+
</select>
|
|
202
|
+
{/if}
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
205
|
+
|
|
206
|
+
{#key view}
|
|
207
|
+
<div class="tasks-view flex min-h-0 flex-1 flex-col">
|
|
208
|
+
{#if view === "board"}
|
|
209
|
+
<KanbanBoard
|
|
210
|
+
{columns}
|
|
211
|
+
items={tasks}
|
|
212
|
+
getId={(task) => task.id}
|
|
213
|
+
getColumn={(task) => task.status}
|
|
214
|
+
{sortKey}
|
|
215
|
+
{sortKeys}
|
|
216
|
+
boardOrder={(task) => task.boardOrder}
|
|
217
|
+
{onMove}
|
|
218
|
+
>
|
|
219
|
+
{#snippet renderCard(task)}
|
|
220
|
+
{@const d = due(task)}
|
|
221
|
+
<article class="rounded-xl border border-border bg-card p-2.5 text-card-foreground shadow-sm">
|
|
222
|
+
<p class="text-sm leading-snug">{task.title}</p>
|
|
223
|
+
<div class="mt-2.5 flex items-center gap-2 text-xs">
|
|
224
|
+
{@render avatar(task.assignee)}
|
|
225
|
+
<span class={d.late ? "font-medium text-destructive" : "text-muted-foreground"}>{d.label}</span>
|
|
226
|
+
{#if tracking}
|
|
227
|
+
<TimeChip minutes={minutesByTask[task.id] ?? 0} estimate={estimates[task.id]} running={timer?.taskId === task.id} />
|
|
228
|
+
{/if}
|
|
229
|
+
<span class="ml-auto rounded-full px-1.5 py-0.5 font-medium capitalize {priorityClass[task.priority]}">
|
|
230
|
+
{task.priority}
|
|
231
|
+
</span>
|
|
232
|
+
</div>
|
|
233
|
+
</article>
|
|
234
|
+
{/snippet}
|
|
235
|
+
</KanbanBoard>
|
|
236
|
+
{:else if view === "list"}
|
|
237
|
+
<TaskList
|
|
238
|
+
groups={columns}
|
|
239
|
+
items={tasks}
|
|
240
|
+
getId={(task) => task.id}
|
|
241
|
+
getGroup={(task) => task.status}
|
|
242
|
+
{sortKey}
|
|
243
|
+
{sortKeys}
|
|
244
|
+
boardOrder={(task) => task.boardOrder}
|
|
245
|
+
{onMove}
|
|
246
|
+
>
|
|
247
|
+
{#snippet head()}
|
|
248
|
+
<div class="{listGrid} w-full px-3.5">
|
|
249
|
+
<span class="pl-6">Task</span>
|
|
250
|
+
<span class="max-sm:hidden">Assignee</span>
|
|
251
|
+
<span class="max-sm:hidden">Priority</span>
|
|
252
|
+
<span class="text-right">Due</span>
|
|
253
|
+
</div>
|
|
254
|
+
{/snippet}
|
|
255
|
+
{#snippet renderRow(task)}
|
|
256
|
+
{@const d = due(task)}
|
|
257
|
+
<div class="{listGrid} h-10 px-2 text-sm">
|
|
258
|
+
<span class="flex min-w-0 items-center gap-2.5">
|
|
259
|
+
{#key task.status}
|
|
260
|
+
<StatusDot statuses={statuses} status={task.status} />
|
|
261
|
+
{/key}
|
|
262
|
+
<span class="truncate" class:text-muted-foreground={task.status === "done"} class:line-through={task.status === "done"}>
|
|
263
|
+
{task.title}
|
|
264
|
+
</span>
|
|
265
|
+
{#if tracking}
|
|
266
|
+
<TimeChip minutes={minutesByTask[task.id] ?? 0} estimate={estimates[task.id]} running={timer?.taskId === task.id} />
|
|
267
|
+
{/if}
|
|
268
|
+
</span>
|
|
269
|
+
<span class="flex min-w-0 items-center gap-2 text-muted-foreground max-sm:hidden">
|
|
270
|
+
{@render avatar(task.assignee)}
|
|
271
|
+
<span class="truncate">{task.assignee}</span>
|
|
272
|
+
</span>
|
|
273
|
+
<span class="max-sm:hidden">
|
|
274
|
+
<span class="rounded-full px-1.5 py-0.5 text-xs font-medium capitalize {priorityClass[task.priority]}">
|
|
275
|
+
{task.priority}
|
|
276
|
+
</span>
|
|
277
|
+
</span>
|
|
278
|
+
<span class="text-right text-xs tabular-nums {d.late ? 'font-medium text-destructive' : 'text-muted-foreground'}">
|
|
279
|
+
{d.label}
|
|
280
|
+
</span>
|
|
281
|
+
</div>
|
|
282
|
+
{/snippet}
|
|
283
|
+
</TaskList>
|
|
284
|
+
{:else}
|
|
285
|
+
<TaskGantt
|
|
286
|
+
items={tasks}
|
|
287
|
+
getId={(task) => task.id}
|
|
288
|
+
getTitle={(task) => task.title}
|
|
289
|
+
getStart={(task) => task.start}
|
|
290
|
+
getEnd={(task) => task.end}
|
|
291
|
+
getProgress={(task) => progressByStatus[task.status]}
|
|
292
|
+
getDependsOn={(task) => task.dependsOn}
|
|
293
|
+
groups={columns}
|
|
294
|
+
getGroup={(task) => task.status}
|
|
295
|
+
{today}
|
|
296
|
+
dayWidth={scale === "days" ? 32 : 14}
|
|
297
|
+
labelWidth={220}
|
|
298
|
+
{onChange}
|
|
299
|
+
>
|
|
300
|
+
{#snippet renderLabel(task)}
|
|
301
|
+
<StatusDot statuses={statuses} status={task.status} />
|
|
302
|
+
<span class="min-w-0 flex-1 truncate">{task.title}</span>
|
|
303
|
+
{@render avatar(task.assignee)}
|
|
304
|
+
{/snippet}
|
|
305
|
+
</TaskGantt>
|
|
306
|
+
{/if}
|
|
307
|
+
</div>
|
|
308
|
+
{/key}
|
|
309
|
+
|
|
310
|
+
<p class="text-xs text-muted-foreground/70">
|
|
311
|
+
{#if view === "board"}
|
|
312
|
+
Drag a card between columns, or focus one and press Alt plus Left or Right.
|
|
313
|
+
{:else if view === "list"}
|
|
314
|
+
Drag a row between groups, or focus one and press Alt plus Up or Down.
|
|
315
|
+
{:else}
|
|
316
|
+
Drag a bar to move it, or its edges to resize. Arrow keys nudge a focused bar a day.
|
|
317
|
+
{/if}
|
|
318
|
+
</p>
|
|
319
|
+
</div>
|
|
320
|
+
|
|
321
|
+
{#if tracking}
|
|
322
|
+
<section class="flex flex-col gap-3 border-t border-border pt-4">
|
|
323
|
+
<div class="flex items-center justify-between gap-3">
|
|
324
|
+
<h3 class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Task time</h3>
|
|
325
|
+
<select
|
|
326
|
+
bind:value={timeTaskId}
|
|
327
|
+
aria-label="Task"
|
|
328
|
+
class="h-8 max-w-[60%] rounded-md border border-border bg-background px-2 text-xs text-foreground"
|
|
329
|
+
>
|
|
330
|
+
{#each tasks as task (task.id)}
|
|
331
|
+
<option value={task.id}>{task.title}</option>
|
|
332
|
+
{/each}
|
|
333
|
+
</select>
|
|
334
|
+
</div>
|
|
335
|
+
<TimeTracker
|
|
336
|
+
class="mx-auto w-full max-w-xl"
|
|
337
|
+
entries={timeEntries}
|
|
338
|
+
getId={(entry) => entry.id}
|
|
339
|
+
getMinutes={(entry) => entry.minutes}
|
|
340
|
+
getDay={(entry) => entry.day}
|
|
341
|
+
getPerson={(entry) => entry.person}
|
|
342
|
+
getNote={(entry) => entry.note}
|
|
343
|
+
estimate={estimates[timeTaskId]}
|
|
344
|
+
running={timeRunning}
|
|
345
|
+
onStart={() => {
|
|
346
|
+
timer = { taskId: timeTaskId, startedAt: Date.now() };
|
|
347
|
+
}}
|
|
348
|
+
{onStop}
|
|
349
|
+
{onLog}
|
|
350
|
+
onDelete={(entry) => {
|
|
351
|
+
entries = entries.filter((row) => row.id !== entry.id);
|
|
352
|
+
}}
|
|
353
|
+
/>
|
|
354
|
+
</section>
|
|
355
|
+
{/if}
|
|
356
|
+
|
|
357
|
+
<section class="flex flex-col gap-2 border-t border-border pt-4">
|
|
358
|
+
<h3 class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Status badges</h3>
|
|
359
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
360
|
+
{#each salesStatuses.ids as id (id)}
|
|
361
|
+
<StatusBadge statuses={salesStatuses} status={id} />
|
|
362
|
+
{/each}
|
|
363
|
+
</div>
|
|
364
|
+
<ul class="mt-1 flex flex-col gap-1.5">
|
|
365
|
+
{#each salesStatuses.ids.slice(0, 3) as id (id)}
|
|
366
|
+
<li class="flex items-center gap-3 text-sm">
|
|
367
|
+
<StatusDot statuses={salesStatuses} status={id} />
|
|
368
|
+
<span class="text-foreground">{salesStatuses.label(id)}</span>
|
|
369
|
+
</li>
|
|
370
|
+
{/each}
|
|
371
|
+
</ul>
|
|
372
|
+
</section>
|
|
373
|
+
</div>
|
|
374
|
+
|
|
375
|
+
<style>
|
|
376
|
+
/* Switching views: the new one rises in with a touch of blur, the old one is gone at once. */
|
|
377
|
+
.tasks-view {
|
|
378
|
+
animation: tasks-view-in var(--duration-fast, 250ms) var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1)) backwards;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
@keyframes tasks-view-in {
|
|
382
|
+
from {
|
|
383
|
+
opacity: 0;
|
|
384
|
+
transform: translateY(var(--distance-base, 8px));
|
|
385
|
+
filter: blur(var(--blur-small, 2px));
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
@media (prefers-reduced-motion: reduce) {
|
|
390
|
+
.tasks-view {
|
|
391
|
+
animation: none;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
</style>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
let { size = 16, class: className }: { size?: number; class?: string } = $props();
|
|
3
|
+
</script>
|
|
4
|
+
|
|
5
|
+
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" aria-hidden="true" class={className}>
|
|
6
|
+
<g stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
|
|
7
|
+
<line x1="4" y1="3" x2="4" y2="11"></line>
|
|
8
|
+
<line x1="8" y1="3" x2="8" y2="7.5"></line>
|
|
9
|
+
<line x1="12" y1="3" x2="12" y2="13"></line>
|
|
10
|
+
</g>
|
|
11
|
+
</svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 3.5 5 6.5 8 3.5"/></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M6 4l4 4-4 4" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="8" cy="8" r="6"/><path d="M8 4.75V8l2.25 1.5"/></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><path d="m4 4 8 8M12 4l-8 8"/></svg>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
let { size = 16, class: className }: { size?: number; class?: string } = $props();
|
|
3
|
+
</script>
|
|
4
|
+
|
|
5
|
+
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" aria-hidden="true" class={className}>
|
|
6
|
+
<g stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
|
|
7
|
+
<line x1="2" y1="4" x2="8" y2="4"></line>
|
|
8
|
+
<line x1="5" y1="8" x2="12" y2="8"></line>
|
|
9
|
+
<line x1="9" y1="12" x2="14" y2="12"></line>
|
|
10
|
+
</g>
|
|
11
|
+
</svg>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
let { size = 16, class: className }: { size?: number; class?: string } = $props();
|
|
3
|
+
</script>
|
|
4
|
+
|
|
5
|
+
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" aria-hidden="true" class={className}>
|
|
6
|
+
<g stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
|
|
7
|
+
<line x1="6" y1="4" x2="14" y2="4"></line>
|
|
8
|
+
<line x1="6" y1="8" x2="14" y2="8"></line>
|
|
9
|
+
<line x1="6" y1="12" x2="14" y2="12"></line>
|
|
10
|
+
</g>
|
|
11
|
+
<g fill="currentColor">
|
|
12
|
+
<circle cx="2.75" cy="4" r="1"></circle>
|
|
13
|
+
<circle cx="2.75" cy="8" r="1"></circle>
|
|
14
|
+
<circle cx="2.75" cy="12" r="1"></circle>
|
|
15
|
+
</g>
|
|
16
|
+
</svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4 2v12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none"/><path d="M4.75 2.75h8l-2 3 2 3h-8z" fill="currentColor"/></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 5.6v12.8a1 1 0 0 0 1.5.86l10.6-6.4a1 1 0 0 0 0-1.72L9.5 4.74A1 1 0 0 0 8 5.6Z"/></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="3" opacity="0.25"/><path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" stroke-width="3" stroke-linecap="round"/></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 2.5h2l1 3-1.5 1a7 7 0 0 0 4 4l1-1.5 3 1v2a1.5 1.5 0 0 1-1.5 1.5A10.5 10.5 0 0 1 2 4a1.5 1.5 0 0 1 1.5-1.5Z"/></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3.5" width="12" height="9" rx="1.5"/><path d="m2.5 4.5 5.5 4 5.5-4"/></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="2" width="10" height="12" rx="1.5"/><path d="M5.5 5.5h5M5.5 8h5M5.5 10.5h3"/></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6.5 2.5 5.5 13.5M10.5 2.5l-1 11M3 6h10.5M2.5 10H13"/></svg>
|