@heyramzi/tasks 0.3.0 → 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/README.md +102 -9
- package/package.json +24 -1
- package/src/demo.svelte +96 -1
- package/src/icons/board-icon.svelte +5 -3
- package/src/icons/chevron-down.svg +1 -0
- package/src/icons/clock.svg +1 -0
- package/src/icons/close.svg +1 -0
- package/src/icons/milestone-flag.svg +1 -0
- package/src/icons/play.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 +37 -0
- package/src/kanban-board.svelte +420 -103
- package/src/kanban-types.ts +18 -1
- package/src/status-badge.svelte +18 -5
- package/src/status-dot.svelte +30 -10
- package/src/status-types.ts +5 -2
- package/src/task-gantt.svelte +208 -35
- package/src/task-list.svelte +204 -99
- package/src/task-types.ts +13 -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 +8 -5
- package/src/use-kanban-dnd.svelte.ts +6 -1
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from "svelte";
|
|
3
|
+
import { cn } from "../lib/cn.js";
|
|
4
|
+
import { TONE_TEXT } from "../tone.js";
|
|
5
|
+
import { budgetTone } from "./duration.js";
|
|
6
|
+
|
|
7
|
+
type Props = {
|
|
8
|
+
/** Minutes logged. */
|
|
9
|
+
value: number;
|
|
10
|
+
/** Minutes budgeted. The ring turns warning at 90% and destructive past 100%. */
|
|
11
|
+
max: number;
|
|
12
|
+
/** Pixels. */
|
|
13
|
+
size?: number;
|
|
14
|
+
/** Stroke width in a 36 unit box. */
|
|
15
|
+
stroke?: number;
|
|
16
|
+
label?: string;
|
|
17
|
+
/** Rendered in the middle, e.g. the percentage. */
|
|
18
|
+
children?: Snippet;
|
|
19
|
+
class?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
let { value, max, size = 56, stroke = 4, label, children, class: className }: Props = $props();
|
|
23
|
+
|
|
24
|
+
const R = 16;
|
|
25
|
+
let tone = $derived(budgetTone(value, max));
|
|
26
|
+
let fraction = $derived(max > 0 ? Math.min(1, Math.max(0, value / max)) : 0);
|
|
27
|
+
let colorClass = $derived(tone === "neutral" ? TONE_TEXT.accent : TONE_TEXT[tone]);
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<span
|
|
31
|
+
class={cn("relative inline-grid shrink-0 place-items-center", className)}
|
|
32
|
+
style:width="{size}px"
|
|
33
|
+
style:height="{size}px"
|
|
34
|
+
role={label ? "img" : undefined}
|
|
35
|
+
aria-label={label}
|
|
36
|
+
aria-hidden={label ? undefined : "true"}
|
|
37
|
+
>
|
|
38
|
+
<svg viewBox="0 0 36 36" class="size-full -rotate-90" fill="none" aria-hidden="true">
|
|
39
|
+
<circle cx="18" cy="18" r={R} stroke="currentColor" stroke-width={stroke} class="text-foreground/10" />
|
|
40
|
+
<circle
|
|
41
|
+
cx="18"
|
|
42
|
+
cy="18"
|
|
43
|
+
r={R}
|
|
44
|
+
pathLength="100"
|
|
45
|
+
stroke="currentColor"
|
|
46
|
+
stroke-width={stroke}
|
|
47
|
+
stroke-linecap="round"
|
|
48
|
+
stroke-dasharray="{fraction * 100} 100"
|
|
49
|
+
class={cn("time-ring-arc", colorClass)}
|
|
50
|
+
class:is-empty={fraction === 0}
|
|
51
|
+
/>
|
|
52
|
+
</svg>
|
|
53
|
+
{#if children}
|
|
54
|
+
<span class="absolute inset-0 grid place-items-center">{@render children()}</span>
|
|
55
|
+
{/if}
|
|
56
|
+
</span>
|
|
57
|
+
|
|
58
|
+
<style>
|
|
59
|
+
.time-ring-arc {
|
|
60
|
+
transition:
|
|
61
|
+
stroke-dasharray var(--duration-slow, 400ms) var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1)),
|
|
62
|
+
color var(--duration-fast, 250ms) var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.time-ring-arc.is-empty {
|
|
66
|
+
opacity: 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
@media (prefers-reduced-motion: reduce) {
|
|
70
|
+
.time-ring-arc {
|
|
71
|
+
transition: none;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
</style>
|
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
<script lang="ts" generics="T">
|
|
2
|
+
import type { Snippet } from "svelte";
|
|
3
|
+
import { cn } from "../lib/cn.js";
|
|
4
|
+
import { TONE_TEXT } from "../tone.js";
|
|
5
|
+
import {
|
|
6
|
+
budgetTone,
|
|
7
|
+
dayLabel,
|
|
8
|
+
formatClock,
|
|
9
|
+
formatMinutes,
|
|
10
|
+
localDay,
|
|
11
|
+
parseMinutes,
|
|
12
|
+
personColor,
|
|
13
|
+
} from "./duration.js";
|
|
14
|
+
import { DEFAULT_TIME_LABELS } from "./time-types.js";
|
|
15
|
+
import type { TimeLabels, TimeLogInput, TimeRunning, TimeStopInfo } from "./time-types.js";
|
|
16
|
+
import TimeDot from "./time-dot.svelte";
|
|
17
|
+
import TimeRing from "./time-ring.svelte";
|
|
18
|
+
import closeIcon from "../icons/close.svg?raw";
|
|
19
|
+
import playIcon from "../icons/play.svg?raw";
|
|
20
|
+
|
|
21
|
+
type Props<T> = {
|
|
22
|
+
/** Every entry logged on the task. Order within a day is kept. */
|
|
23
|
+
entries: T[];
|
|
24
|
+
getId: (entry: T) => string;
|
|
25
|
+
getMinutes: (entry: T) => number;
|
|
26
|
+
/** The day worked, `YYYY-MM-DD`. */
|
|
27
|
+
getDay: (entry: T) => string;
|
|
28
|
+
/** The name shown for the person who logged it. */
|
|
29
|
+
getPerson: (entry: T) => string;
|
|
30
|
+
/** Groups the split by id when two people share a name. Defaults to the name. */
|
|
31
|
+
getPersonId?: (entry: T) => string;
|
|
32
|
+
getNote?: (entry: T) => string | null | undefined;
|
|
33
|
+
/** The timer in progress, or null. The host stores it, so it survives a reload. */
|
|
34
|
+
running?: TimeRunning | null;
|
|
35
|
+
/** Minutes budgeted for the task. Draws the progress ring. */
|
|
36
|
+
estimate?: number;
|
|
37
|
+
onStart?: () => void | Promise<void>;
|
|
38
|
+
/** Called with the elapsed time. Log it, then clear `running`. */
|
|
39
|
+
onStop?: (info: TimeStopInfo) => void | Promise<void>;
|
|
40
|
+
/** A manual entry. Return false, or throw, to keep what was typed. */
|
|
41
|
+
onLog: (input: TimeLogInput) => void | boolean | Promise<void | boolean>;
|
|
42
|
+
/** Shows a delete button on rows where `canDelete` allows it. */
|
|
43
|
+
onDelete?: (entry: T) => void | Promise<void>;
|
|
44
|
+
canDelete?: (entry: T) => boolean;
|
|
45
|
+
/** Extra text after the note, e.g. the project. */
|
|
46
|
+
renderMeta?: Snippet<[T]>;
|
|
47
|
+
/** BCP 47 tag for day names. Defaults to the browser's. */
|
|
48
|
+
locale?: string;
|
|
49
|
+
labels?: Partial<TimeLabels>;
|
|
50
|
+
/** Drops the card: no border, radius, background, shadow or padding. For a host that already sits the tracker in a panel. */
|
|
51
|
+
flat?: boolean;
|
|
52
|
+
class?: string;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
let {
|
|
56
|
+
entries,
|
|
57
|
+
getId,
|
|
58
|
+
getMinutes,
|
|
59
|
+
getDay,
|
|
60
|
+
getPerson,
|
|
61
|
+
getPersonId,
|
|
62
|
+
getNote,
|
|
63
|
+
running = null,
|
|
64
|
+
estimate,
|
|
65
|
+
onStart,
|
|
66
|
+
onStop,
|
|
67
|
+
onLog,
|
|
68
|
+
onDelete,
|
|
69
|
+
canDelete,
|
|
70
|
+
renderMeta,
|
|
71
|
+
locale,
|
|
72
|
+
labels: labelOverrides,
|
|
73
|
+
flat = false,
|
|
74
|
+
class: className,
|
|
75
|
+
}: Props<T> = $props();
|
|
76
|
+
|
|
77
|
+
let labels = $derived({ ...DEFAULT_TIME_LABELS, ...labelOverrides });
|
|
78
|
+
|
|
79
|
+
// ---- timer
|
|
80
|
+
let now = $state(Date.now());
|
|
81
|
+
let stoppedNote = $state("");
|
|
82
|
+
let startedMs = $derived(running ? new Date(running.startedAt).getTime() : null);
|
|
83
|
+
let elapsedMs = $derived(startedMs === null ? 0 : Math.max(0, now - startedMs));
|
|
84
|
+
let clock = $derived(formatClock(elapsedMs));
|
|
85
|
+
let elapsedMinutes = $derived(Math.floor(elapsedMs / 60_000));
|
|
86
|
+
|
|
87
|
+
// The tick runs only while a timer does, and re-reads the clock so a sleeping tab catches up.
|
|
88
|
+
$effect(() => {
|
|
89
|
+
if (startedMs === null) return;
|
|
90
|
+
now = Date.now();
|
|
91
|
+
const id = setInterval(() => (now = Date.now()), 1000);
|
|
92
|
+
return () => clearInterval(id);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Screen readers hear the start, then one update a minute, then the stop.
|
|
96
|
+
let status = $derived(
|
|
97
|
+
running
|
|
98
|
+
? elapsedMinutes === 0
|
|
99
|
+
? labels.started
|
|
100
|
+
: labels.runningFor(formatMinutes(elapsedMinutes))
|
|
101
|
+
: stoppedNote,
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
function sinceLabel(): string {
|
|
105
|
+
if (startedMs === null) return "";
|
|
106
|
+
return labels.since(
|
|
107
|
+
new Date(startedMs).toLocaleTimeString(locale, { hour: "numeric", minute: "2-digit" }),
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function toggle(): Promise<void> {
|
|
112
|
+
if (!running || startedMs === null) {
|
|
113
|
+
stoppedNote = "";
|
|
114
|
+
await onStart?.();
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const stoppedAt = new Date();
|
|
118
|
+
const startedAt = new Date(startedMs);
|
|
119
|
+
const minutes = Math.max(1, Math.round((stoppedAt.getTime() - startedMs) / 60_000));
|
|
120
|
+
stoppedNote = labels.stopped(formatMinutes(minutes));
|
|
121
|
+
await onStop?.({ minutes, startedAt, stoppedAt, day: localDay(startedAt) });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function onRootKeyDown(event: KeyboardEvent): void {
|
|
125
|
+
if (event.altKey && event.code === "KeyS" && !event.repeat) {
|
|
126
|
+
event.preventDefault();
|
|
127
|
+
void toggle();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---- manual entry
|
|
132
|
+
let duration = $state("");
|
|
133
|
+
let day = $state(localDay());
|
|
134
|
+
let note = $state("");
|
|
135
|
+
let saving = $state(false);
|
|
136
|
+
let invalid = $state(false);
|
|
137
|
+
let durationInput = $state<HTMLInputElement>();
|
|
138
|
+
let parsed = $derived(parseMinutes(duration));
|
|
139
|
+
let preview = $derived(parsed !== null && parsed >= 1 && parsed <= 1440 ? formatMinutes(parsed) : "");
|
|
140
|
+
|
|
141
|
+
async function submit(event: SubmitEvent): Promise<void> {
|
|
142
|
+
event.preventDefault();
|
|
143
|
+
invalid = parsed === null || parsed < 1 || parsed > 1440;
|
|
144
|
+
if (invalid || parsed === null || saving) return;
|
|
145
|
+
saving = true;
|
|
146
|
+
try {
|
|
147
|
+
const result = await onLog({ minutes: parsed, day, note: note.trim() || undefined });
|
|
148
|
+
if (result !== false) {
|
|
149
|
+
duration = "";
|
|
150
|
+
note = "";
|
|
151
|
+
durationInput?.focus();
|
|
152
|
+
}
|
|
153
|
+
} catch {
|
|
154
|
+
// The host owns the error message. The fields stay as typed.
|
|
155
|
+
} finally {
|
|
156
|
+
saving = false;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ---- summary
|
|
161
|
+
let total = $derived(entries.reduce((sum, entry) => sum + getMinutes(entry), 0));
|
|
162
|
+
let liveTotal = $derived(total + elapsedMinutes);
|
|
163
|
+
let tone = $derived(budgetTone(liveTotal, estimate));
|
|
164
|
+
let percent = $derived(estimate ? Math.round((liveTotal / estimate) * 100) : 0);
|
|
165
|
+
|
|
166
|
+
let groups = $derived.by(() => {
|
|
167
|
+
const byDay = new Map<string, { day: string; items: T[]; minutes: number }>();
|
|
168
|
+
for (const entry of entries) {
|
|
169
|
+
const key = getDay(entry);
|
|
170
|
+
const group = byDay.get(key) ?? { day: key, items: [], minutes: 0 };
|
|
171
|
+
group.items.push(entry);
|
|
172
|
+
group.minutes += getMinutes(entry);
|
|
173
|
+
byDay.set(key, group);
|
|
174
|
+
}
|
|
175
|
+
return [...byDay.values()].sort((a, b) => (a.day < b.day ? 1 : a.day > b.day ? -1 : 0));
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
let people = $derived.by(() => {
|
|
179
|
+
const byPerson = new Map<string, { key: string; name: string; minutes: number }>();
|
|
180
|
+
for (const entry of entries) {
|
|
181
|
+
const key = getPersonId?.(entry) ?? getPerson(entry);
|
|
182
|
+
const person = byPerson.get(key) ?? { key, name: getPerson(entry), minutes: 0 };
|
|
183
|
+
person.minutes += getMinutes(entry);
|
|
184
|
+
byPerson.set(key, person);
|
|
185
|
+
}
|
|
186
|
+
return [...byPerson.values()].sort((a, b) => b.minutes - a.minutes);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// Rows that arrive after the first paint ease in. The first paint shows them as they are.
|
|
190
|
+
// svelte-ignore state_referenced_locally
|
|
191
|
+
const firstIds = new Set(entries.map(getId));
|
|
192
|
+
|
|
193
|
+
let deleting = $state<string | null>(null);
|
|
194
|
+
|
|
195
|
+
async function remove(entry: T): Promise<void> {
|
|
196
|
+
deleting = getId(entry);
|
|
197
|
+
try {
|
|
198
|
+
await onDelete?.(entry);
|
|
199
|
+
} finally {
|
|
200
|
+
deleting = null;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function initial(name: string): string {
|
|
205
|
+
return name.trim().charAt(0).toLocaleUpperCase() || "?";
|
|
206
|
+
}
|
|
207
|
+
</script>
|
|
208
|
+
|
|
209
|
+
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
210
|
+
<section
|
|
211
|
+
class={cn("flex flex-col gap-5", className)}
|
|
212
|
+
aria-label={labels.title}
|
|
213
|
+
onkeydown={onRootKeyDown}
|
|
214
|
+
>
|
|
215
|
+
<div class="sr-only" role="status" aria-live="polite">{status}</div>
|
|
216
|
+
|
|
217
|
+
<!-- Timer and manual entry -->
|
|
218
|
+
<div
|
|
219
|
+
class={cn("time-panel", !flat && "rounded-2xl border border-border bg-card p-4 text-card-foreground shadow-sm")}
|
|
220
|
+
class:is-running={!!running && !flat}
|
|
221
|
+
>
|
|
222
|
+
<div class="flex items-center gap-4">
|
|
223
|
+
<div class="min-w-0 flex-1">
|
|
224
|
+
<p
|
|
225
|
+
class={cn(
|
|
226
|
+
"flex h-5 items-center gap-2 text-xs font-medium",
|
|
227
|
+
running ? TONE_TEXT.accent : "text-muted-foreground",
|
|
228
|
+
)}
|
|
229
|
+
>
|
|
230
|
+
<TimeDot running={!!running} />
|
|
231
|
+
<span>{running ? labels.running : labels.timer}</span>
|
|
232
|
+
{#if running}
|
|
233
|
+
<span class="font-normal text-muted-foreground">{sinceLabel()}</span>
|
|
234
|
+
{/if}
|
|
235
|
+
</p>
|
|
236
|
+
<p
|
|
237
|
+
role="timer"
|
|
238
|
+
aria-label={labels.elapsed}
|
|
239
|
+
class={cn("time-readout mt-2 flex items-baseline text-[2.5rem] leading-none font-medium tracking-tight tabular-nums", running ? "text-foreground" : "text-muted-foreground/50")}
|
|
240
|
+
>
|
|
241
|
+
<span>{clock.hours}</span><span class="time-colon">:</span><span>{clock.minutes}</span><span class="time-colon">:</span><span>{clock.seconds}</span>
|
|
242
|
+
</p>
|
|
243
|
+
</div>
|
|
244
|
+
|
|
245
|
+
<button
|
|
246
|
+
type="button"
|
|
247
|
+
onclick={toggle}
|
|
248
|
+
aria-label={running ? labels.stop : labels.start}
|
|
249
|
+
aria-keyshortcuts="Alt+S"
|
|
250
|
+
title="{running ? labels.stop : labels.start} (Alt+S)"
|
|
251
|
+
class={cn(
|
|
252
|
+
"time-toggle relative grid size-12 shrink-0 place-items-center rounded-full outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-card",
|
|
253
|
+
running ? "bg-foreground text-background" : "bg-primary text-primary-foreground",
|
|
254
|
+
)}
|
|
255
|
+
class:is-running={!!running}
|
|
256
|
+
>
|
|
257
|
+
{#if running}
|
|
258
|
+
<svg viewBox="0 0 24 24" class="size-4.5" fill="currentColor" aria-hidden="true">
|
|
259
|
+
<rect x="6" y="6" width="12" height="12" rx="2.5" />
|
|
260
|
+
</svg>
|
|
261
|
+
{:else}
|
|
262
|
+
<span class="size-5 translate-x-px [&>svg]:size-full" aria-hidden="true">{@html playIcon}</span>
|
|
263
|
+
{/if}
|
|
264
|
+
</button>
|
|
265
|
+
</div>
|
|
266
|
+
|
|
267
|
+
<form
|
|
268
|
+
onsubmit={submit}
|
|
269
|
+
class="mt-4 grid grid-cols-2 items-center gap-2 border-t border-border/60 pt-4 sm:grid-cols-[7rem_8.75rem_minmax(0,1fr)_auto]"
|
|
270
|
+
>
|
|
271
|
+
<div class="relative">
|
|
272
|
+
<input
|
|
273
|
+
bind:this={durationInput}
|
|
274
|
+
bind:value={duration}
|
|
275
|
+
oninput={() => (invalid = false)}
|
|
276
|
+
placeholder={labels.durationPlaceholder}
|
|
277
|
+
aria-label={labels.duration}
|
|
278
|
+
aria-invalid={invalid}
|
|
279
|
+
aria-describedby={invalid ? "time-invalid" : undefined}
|
|
280
|
+
autocomplete="off"
|
|
281
|
+
inputmode="text"
|
|
282
|
+
class={cn("time-field w-full tabular-nums", preview && "pr-12")}
|
|
283
|
+
/>
|
|
284
|
+
{#if preview && preview !== duration.trim()}
|
|
285
|
+
<span class="time-preview pointer-events-none absolute inset-y-0 right-2.5 flex items-center text-xs text-muted-foreground tabular-nums" aria-hidden="true">
|
|
286
|
+
{preview}
|
|
287
|
+
</span>
|
|
288
|
+
{/if}
|
|
289
|
+
</div>
|
|
290
|
+
<input
|
|
291
|
+
type="date"
|
|
292
|
+
bind:value={day}
|
|
293
|
+
aria-label={labels.date}
|
|
294
|
+
class="time-field w-full tabular-nums"
|
|
295
|
+
/>
|
|
296
|
+
<input
|
|
297
|
+
bind:value={note}
|
|
298
|
+
placeholder={labels.notePlaceholder}
|
|
299
|
+
aria-label={labels.note}
|
|
300
|
+
autocomplete="off"
|
|
301
|
+
class="time-field col-span-2 w-full sm:col-span-1"
|
|
302
|
+
/>
|
|
303
|
+
<button
|
|
304
|
+
type="submit"
|
|
305
|
+
disabled={saving || !duration.trim()}
|
|
306
|
+
class="col-span-2 inline-flex h-9 items-center justify-center rounded-md sm:col-span-1 border border-border bg-background px-3.5 text-sm font-medium text-foreground transition-colors outline-none hover:bg-muted focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50"
|
|
307
|
+
>
|
|
308
|
+
{saving ? labels.saving : labels.log}
|
|
309
|
+
</button>
|
|
310
|
+
</form>
|
|
311
|
+
{#if invalid}
|
|
312
|
+
<p id="time-invalid" class="mt-2 text-xs text-destructive" role="alert">{labels.invalid}</p>
|
|
313
|
+
{/if}
|
|
314
|
+
</div>
|
|
315
|
+
|
|
316
|
+
<!-- Total, budget, split -->
|
|
317
|
+
{#if entries.length > 0 || estimate}
|
|
318
|
+
<div class="flex flex-col gap-4 px-1">
|
|
319
|
+
<div class="flex items-center gap-4">
|
|
320
|
+
{#if estimate}
|
|
321
|
+
<TimeRing value={liveTotal} max={estimate} size={60} stroke={4}>
|
|
322
|
+
<span class={cn("text-xs font-semibold tabular-nums", tone === "neutral" ? "text-foreground" : TONE_TEXT[tone])}>{percent}%</span>
|
|
323
|
+
</TimeRing>
|
|
324
|
+
{/if}
|
|
325
|
+
<div class="min-w-0">
|
|
326
|
+
<p class="text-xs font-medium text-muted-foreground">{labels.logged}</p>
|
|
327
|
+
{#key total}
|
|
328
|
+
<p class="time-pop mt-0.5 text-2xl leading-tight font-semibold tracking-tight tabular-nums">
|
|
329
|
+
{formatMinutes(total)}
|
|
330
|
+
</p>
|
|
331
|
+
{/key}
|
|
332
|
+
{#if estimate}
|
|
333
|
+
<p class={cn("mt-0.5 text-xs tabular-nums", tone === "neutral" ? "text-muted-foreground" : TONE_TEXT[tone])}>
|
|
334
|
+
{liveTotal > estimate
|
|
335
|
+
? labels.over(formatMinutes(liveTotal - estimate), formatMinutes(estimate))
|
|
336
|
+
: labels.remaining(formatMinutes(estimate - liveTotal), formatMinutes(estimate))}
|
|
337
|
+
</p>
|
|
338
|
+
{/if}
|
|
339
|
+
</div>
|
|
340
|
+
</div>
|
|
341
|
+
|
|
342
|
+
{#if people.length > 1}
|
|
343
|
+
<div>
|
|
344
|
+
<div class="flex h-1.5 gap-0.5 overflow-hidden rounded-full" aria-hidden="true">
|
|
345
|
+
{#each people as person (person.key)}
|
|
346
|
+
<span
|
|
347
|
+
class="time-segment min-w-1 rounded-full"
|
|
348
|
+
style:flex-grow={person.minutes}
|
|
349
|
+
style:background={personColor(person.key)}
|
|
350
|
+
></span>
|
|
351
|
+
{/each}
|
|
352
|
+
</div>
|
|
353
|
+
<ul class="mt-2.5 flex flex-wrap gap-x-4 gap-y-1">
|
|
354
|
+
{#each people as person (person.key)}
|
|
355
|
+
<li class="flex items-center gap-1.5 text-xs">
|
|
356
|
+
<span class="size-2 rounded-full" style:background={personColor(person.key)} aria-hidden="true"></span>
|
|
357
|
+
<span class="text-muted-foreground">{person.name}</span>
|
|
358
|
+
<span class="font-medium tabular-nums">{formatMinutes(person.minutes)}</span>
|
|
359
|
+
</li>
|
|
360
|
+
{/each}
|
|
361
|
+
</ul>
|
|
362
|
+
</div>
|
|
363
|
+
{/if}
|
|
364
|
+
</div>
|
|
365
|
+
{/if}
|
|
366
|
+
|
|
367
|
+
<!-- Entries by day -->
|
|
368
|
+
{#if groups.length > 0}
|
|
369
|
+
<div class="flex flex-col gap-4">
|
|
370
|
+
{#each groups as group (group.day)}
|
|
371
|
+
<div>
|
|
372
|
+
<div class="flex items-baseline justify-between border-b border-border/60 px-2 pb-1.5">
|
|
373
|
+
<h3 class="text-xs font-medium text-muted-foreground">{dayLabel(group.day, locale)}</h3>
|
|
374
|
+
<span class="text-xs font-medium text-muted-foreground tabular-nums">{formatMinutes(group.minutes)}</span>
|
|
375
|
+
</div>
|
|
376
|
+
<ul class="mt-1">
|
|
377
|
+
{#each group.items as entry (getId(entry))}
|
|
378
|
+
{@const name = getPerson(entry)}
|
|
379
|
+
{@const text = getNote?.(entry)}
|
|
380
|
+
<li
|
|
381
|
+
class="time-row group flex items-center gap-3 rounded-lg px-2 py-1.5 text-sm transition-colors hover:bg-muted/50 focus-within:bg-muted/50"
|
|
382
|
+
class:is-new={!firstIds.has(getId(entry))}
|
|
383
|
+
class:opacity-50={deleting === getId(entry)}
|
|
384
|
+
>
|
|
385
|
+
<span
|
|
386
|
+
class="grid size-5 shrink-0 place-items-center rounded-full text-[10px] font-semibold text-white"
|
|
387
|
+
style:background={personColor(getPersonId?.(entry) ?? name)}
|
|
388
|
+
title={name}
|
|
389
|
+
aria-hidden="true"
|
|
390
|
+
>
|
|
391
|
+
{initial(name)}
|
|
392
|
+
</span>
|
|
393
|
+
<span class="min-w-0 flex-1 truncate">
|
|
394
|
+
<span class="sr-only">{name},</span>
|
|
395
|
+
{#if text}{text}{:else}<span class="text-muted-foreground">{name}</span>{/if}
|
|
396
|
+
{#if renderMeta}<span class="ms-2 text-xs text-muted-foreground">{@render renderMeta(entry)}</span>{/if}
|
|
397
|
+
</span>
|
|
398
|
+
<span class="shrink-0 font-medium tabular-nums">{formatMinutes(getMinutes(entry))}</span>
|
|
399
|
+
{#if onDelete && (canDelete?.(entry) ?? true)}
|
|
400
|
+
<button
|
|
401
|
+
type="button"
|
|
402
|
+
onclick={() => remove(entry)}
|
|
403
|
+
disabled={deleting === getId(entry)}
|
|
404
|
+
aria-label={labels.deleteEntry(formatMinutes(getMinutes(entry)))}
|
|
405
|
+
class="time-delete -my-1 -me-1 grid size-8 shrink-0 place-items-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-destructive/10 hover:text-destructive focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
|
406
|
+
>
|
|
407
|
+
<span class="size-3.5 [&>svg]:size-full" aria-hidden="true">{@html closeIcon}</span>
|
|
408
|
+
</button>
|
|
409
|
+
{/if}
|
|
410
|
+
</li>
|
|
411
|
+
{/each}
|
|
412
|
+
</ul>
|
|
413
|
+
</div>
|
|
414
|
+
{/each}
|
|
415
|
+
</div>
|
|
416
|
+
{:else}
|
|
417
|
+
<p class="px-1 py-6 text-center text-sm text-muted-foreground">{labels.empty}</p>
|
|
418
|
+
{/if}
|
|
419
|
+
</section>
|
|
420
|
+
|
|
421
|
+
<style>
|
|
422
|
+
.time-panel {
|
|
423
|
+
transition:
|
|
424
|
+
border-color var(--duration-medium, 350ms) var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1)),
|
|
425
|
+
background-color var(--duration-medium, 350ms) var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1));
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
.time-panel.is-running {
|
|
429
|
+
border-color: color-mix(in oklab, var(--tone-accent, var(--color-primary)) 32%, var(--color-border));
|
|
430
|
+
background-color: color-mix(in oklab, var(--tone-accent, var(--color-primary)) 4%, var(--color-card));
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
.time-readout {
|
|
434
|
+
transition: color var(--duration-fast, 250ms) var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1));
|
|
435
|
+
font-feature-settings: "tnum" 1;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
.time-colon {
|
|
439
|
+
margin-inline: 0.04em;
|
|
440
|
+
opacity: 0.45;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
.time-toggle {
|
|
444
|
+
transition:
|
|
445
|
+
transform var(--duration-quick, 150ms) var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1)),
|
|
446
|
+
background-color var(--duration-fast, 250ms) var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1)),
|
|
447
|
+
color var(--duration-fast, 250ms) var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1));
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
.time-toggle:active {
|
|
451
|
+
transform: scale(0.94);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
.time-toggle.is-running::after {
|
|
455
|
+
content: "";
|
|
456
|
+
position: absolute;
|
|
457
|
+
inset: 0;
|
|
458
|
+
border-radius: inherit;
|
|
459
|
+
border: 1.5px solid var(--tone-accent, var(--color-primary));
|
|
460
|
+
animation: time-halo var(--time-pulse-duration, 2000ms) var(--ease-out, ease-out) infinite;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
.time-field {
|
|
464
|
+
height: 2.25rem;
|
|
465
|
+
border-radius: var(--radius-md, 0.5rem);
|
|
466
|
+
border: 1px solid var(--color-input, var(--color-border));
|
|
467
|
+
background: var(--color-background);
|
|
468
|
+
padding-inline: 0.625rem;
|
|
469
|
+
font-size: 0.875rem;
|
|
470
|
+
color: var(--color-foreground);
|
|
471
|
+
outline: none;
|
|
472
|
+
transition:
|
|
473
|
+
border-color var(--duration-quick, 150ms) var(--ease-smooth-out, ease-out),
|
|
474
|
+
box-shadow var(--duration-quick, 150ms) var(--ease-smooth-out, ease-out);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
.time-field::placeholder {
|
|
478
|
+
color: var(--color-muted-foreground);
|
|
479
|
+
opacity: 0.7;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
.time-field:focus-visible {
|
|
483
|
+
border-color: var(--color-ring);
|
|
484
|
+
box-shadow: 0 0 0 3px color-mix(in oklab, var(--color-ring) 40%, transparent);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
.time-field[aria-invalid="true"] {
|
|
488
|
+
border-color: var(--color-destructive);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
.time-segment {
|
|
492
|
+
transition: flex-grow var(--duration-slow, 400ms) var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1));
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
.time-delete {
|
|
496
|
+
opacity: 0;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
.time-row:hover .time-delete,
|
|
500
|
+
.time-row:focus-within .time-delete {
|
|
501
|
+
opacity: 1;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
@media (hover: none) {
|
|
505
|
+
.time-delete {
|
|
506
|
+
opacity: 1;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
.time-pop,
|
|
511
|
+
.time-preview {
|
|
512
|
+
animation: time-pop var(--duration-quick, 150ms) var(--ease-smooth-out, ease-out) backwards;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
.time-row.is-new {
|
|
516
|
+
animation: time-row-in var(--duration-fast, 250ms) var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1)) backwards;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
@keyframes time-halo {
|
|
520
|
+
from {
|
|
521
|
+
transform: scale(1);
|
|
522
|
+
opacity: 0.55;
|
|
523
|
+
}
|
|
524
|
+
to {
|
|
525
|
+
transform: scale(1.5);
|
|
526
|
+
opacity: 0;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
@keyframes time-pop {
|
|
531
|
+
from {
|
|
532
|
+
opacity: 0;
|
|
533
|
+
transform: translateY(4px);
|
|
534
|
+
filter: blur(var(--blur-small, 2px));
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
@keyframes time-row-in {
|
|
539
|
+
from {
|
|
540
|
+
opacity: 0;
|
|
541
|
+
transform: translateY(var(--distance-base, 8px));
|
|
542
|
+
filter: blur(var(--blur-small, 2px));
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
@media (prefers-reduced-motion: reduce) {
|
|
547
|
+
.time-panel,
|
|
548
|
+
.time-readout,
|
|
549
|
+
.time-toggle,
|
|
550
|
+
.time-field,
|
|
551
|
+
.time-segment {
|
|
552
|
+
transition: none;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
.time-toggle.is-running::after {
|
|
556
|
+
animation: none;
|
|
557
|
+
transform: scale(1.18);
|
|
558
|
+
opacity: 0.4;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
.time-pop,
|
|
562
|
+
.time-preview,
|
|
563
|
+
.time-row.is-new {
|
|
564
|
+
animation: none;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
</style>
|