@giraffesyo/timeclock 0.1.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 +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -0
- package/dist/react.d.ts +3 -0
- package/dist/react.js +74 -8
- package/dist/styles.css +18 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -48,7 +48,7 @@ function HeaderClock() {
|
|
|
48
48
|
}
|
|
49
49
|
```
|
|
50
50
|
|
|
51
|
-
`useClock()` gives the state (`status`, `running`, `projects`, `requireProject`, `locked`, `timeZone`, `busy`), what is being typed (`note`, `setNote`, `projectId`), and what can be done (`start`, `stop`, `chooseProject`, `saveNote`, `resume`). Everything that changes the clock returns a promise that rejects with a `ClockError` when Timeclock refuses.
|
|
51
|
+
`useClock()` gives the state (`status`, `running`, `projects`, `requireProject`, `requireDescription`, `locked`, `timeZone`, `busy`), what is being typed (`note`, `setNote`, `projectId`), and what can be done (`start`, `stop`, `chooseProject`, `saveNote`, `resume`). Everything that changes the clock returns a promise that rejects with a `ClockError` when Timeclock refuses.
|
|
52
52
|
|
|
53
53
|
## The store
|
|
54
54
|
|
package/dist/index.d.ts
CHANGED
|
@@ -25,6 +25,7 @@ export interface ClockState {
|
|
|
25
25
|
projects: Project[];
|
|
26
26
|
/** Whether the organization requires a project on every entry. */
|
|
27
27
|
requireProject: boolean;
|
|
28
|
+
requireDescription: boolean;
|
|
28
29
|
/** The pay period's timesheet is in, so the clock is off. */
|
|
29
30
|
locked: boolean;
|
|
30
31
|
/** The IANA time zone the person's times are shown in. */
|
package/dist/index.js
CHANGED
|
@@ -21,6 +21,7 @@ const initial = {
|
|
|
21
21
|
running: null,
|
|
22
22
|
projects: [],
|
|
23
23
|
requireProject: false,
|
|
24
|
+
requireDescription: false,
|
|
24
25
|
locked: false,
|
|
25
26
|
timeZone: 'UTC',
|
|
26
27
|
busy: false,
|
|
@@ -74,6 +75,7 @@ export function createClock(options = {}) {
|
|
|
74
75
|
running: me.running ?? null,
|
|
75
76
|
projects: projects.projects ?? [],
|
|
76
77
|
requireProject: me.settings.requireProject,
|
|
78
|
+
requireDescription: me.settings.requireDescription ?? false,
|
|
77
79
|
locked: status === 'submitted' || status === 'approved',
|
|
78
80
|
timeZone: me.person.timezone || me.settings.timezone,
|
|
79
81
|
});
|
package/dist/react.d.ts
CHANGED
|
@@ -74,6 +74,9 @@ export interface ClockBarLabels extends ProjectPickerLabels {
|
|
|
74
74
|
locked: string;
|
|
75
75
|
/** When the running stretch started, given the time as text. */
|
|
76
76
|
since: (time: string) => string;
|
|
77
|
+
missingDescription: string;
|
|
78
|
+
missingProject: string;
|
|
79
|
+
missingDescriptionAndProject: string;
|
|
77
80
|
}
|
|
78
81
|
/** What the person was doing when something was refused. */
|
|
79
82
|
export type ClockAction = 'start' | 'stop' | 'switch' | 'note';
|
package/dist/react.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
// The clock for React: useClock() is the logic with no look, and ClockBar
|
|
3
3
|
// and ProjectPicker are a look for it, styled by "@giraffesyo/timeclock/styles.css".
|
|
4
|
-
import { useEffect, useId, useRef, useState, useSyncExternalStore, } from 'react';
|
|
4
|
+
import { useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore, } from 'react';
|
|
5
5
|
import { clockFor, projectHue, projectLabel, stopwatch, } from './index.js';
|
|
6
6
|
/** The clock of the Timeclock at a base path, kept current while the component is mounted. */
|
|
7
7
|
export function useClock(options = {}) {
|
|
@@ -33,7 +33,10 @@ export function useClock(options = {}) {
|
|
|
33
33
|
}
|
|
34
34
|
// A note typed just before choosing a project describes the work being
|
|
35
35
|
// moved to, so it goes with the new stretch.
|
|
36
|
-
return store.switchTo({
|
|
36
|
+
return store.switchTo({
|
|
37
|
+
projectId,
|
|
38
|
+
note: noteEdited || state.requireDescription || !running.projectId ? note : '',
|
|
39
|
+
});
|
|
37
40
|
},
|
|
38
41
|
saveNote,
|
|
39
42
|
start: () => store.start({ projectId: current.projectId, note }),
|
|
@@ -77,6 +80,7 @@ export function ProjectPicker({ projects, value, onChange, required = false, dis
|
|
|
77
80
|
const listId = useId();
|
|
78
81
|
const root = useRef(null);
|
|
79
82
|
const trigger = useRef(null);
|
|
83
|
+
const menu = useRef(null);
|
|
80
84
|
const [open, setOpen] = useState(false);
|
|
81
85
|
const [query, setQuery] = useState('');
|
|
82
86
|
const [cursor, setCursor] = useState(0);
|
|
@@ -87,6 +91,41 @@ export function ProjectPicker({ projects, value, onChange, required = false, dis
|
|
|
87
91
|
...projects.map((p) => ({ id: p.id, name: p.name, group: p.customerName })),
|
|
88
92
|
].filter((o) => words.every((w) => `${o.group} ${o.name}`.toLowerCase().includes(w)));
|
|
89
93
|
const at = Math.min(cursor, Math.max(options.length - 1, 0));
|
|
94
|
+
// A nested native popover escapes a dialog's scrolling/clipping bounds while
|
|
95
|
+
// remaining its descendant, so selecting a project keeps the editor open.
|
|
96
|
+
useLayoutEffect(() => {
|
|
97
|
+
const el = menu.current;
|
|
98
|
+
const button = trigger.current;
|
|
99
|
+
if (!open || !el || !button)
|
|
100
|
+
return;
|
|
101
|
+
el.showPopover();
|
|
102
|
+
const position = () => {
|
|
103
|
+
const rect = button.getBoundingClientRect();
|
|
104
|
+
const below = window.innerHeight - rect.bottom - 12;
|
|
105
|
+
const above = rect.top - 12;
|
|
106
|
+
el.style.maxHeight = `${Math.max(0, Math.max(below, above))}px`;
|
|
107
|
+
const height = el.offsetHeight;
|
|
108
|
+
const down = below >= height || below >= above;
|
|
109
|
+
el.style.maxHeight = `${Math.max(0, down ? below : above)}px`;
|
|
110
|
+
el.style.left = `${Math.max(8, Math.min(rect.left, window.innerWidth - el.offsetWidth - 8))}px`;
|
|
111
|
+
el.style.top = `${down ? rect.bottom + 4 : Math.max(8, rect.top - el.offsetHeight - 4)}px`;
|
|
112
|
+
};
|
|
113
|
+
position();
|
|
114
|
+
el.querySelector('input')?.focus({ preventScroll: true });
|
|
115
|
+
const observer = new ResizeObserver(position);
|
|
116
|
+
observer.observe(el);
|
|
117
|
+
const follow = (e) => {
|
|
118
|
+
if (e.target instanceof Node && !el.contains(e.target))
|
|
119
|
+
position();
|
|
120
|
+
};
|
|
121
|
+
window.addEventListener('resize', position);
|
|
122
|
+
window.addEventListener('scroll', follow, true);
|
|
123
|
+
return () => {
|
|
124
|
+
observer.disconnect();
|
|
125
|
+
window.removeEventListener('resize', position);
|
|
126
|
+
window.removeEventListener('scroll', follow, true);
|
|
127
|
+
};
|
|
128
|
+
}, [open]);
|
|
90
129
|
// A press outside closes the list.
|
|
91
130
|
useEffect(() => {
|
|
92
131
|
if (!open)
|
|
@@ -127,6 +166,7 @@ export function ProjectPicker({ projects, value, onChange, required = false, dis
|
|
|
127
166
|
break;
|
|
128
167
|
}
|
|
129
168
|
case 'Escape':
|
|
169
|
+
e.preventDefault();
|
|
130
170
|
// The list closes; a dialog around it stays.
|
|
131
171
|
e.stopPropagation();
|
|
132
172
|
e.nativeEvent.stopImmediatePropagation();
|
|
@@ -144,12 +184,18 @@ export function ProjectPicker({ projects, value, onChange, required = false, dis
|
|
|
144
184
|
e.preventDefault();
|
|
145
185
|
show();
|
|
146
186
|
}
|
|
147
|
-
}, children: [_jsx(ProjectDot, { projectId: current?.id }), _jsx("span", { className: cx('tc-picker-value', !current && 'tc-muted'), children: shown }), _jsx("svg", { "aria-hidden": true, className: "tc-chevron", viewBox: "0 0 12 12", fill: "none", stroke: "currentColor", strokeWidth: "1.75", children: _jsx("path", { d: "M2.5 4.5 6 8l3.5-3.5", strokeLinecap: "round", strokeLinejoin: "round" }) })] }), open && (_jsxs("div", {
|
|
148
|
-
|
|
149
|
-
|
|
187
|
+
}, children: [_jsx(ProjectDot, { projectId: current?.id }), _jsx("span", { className: cx('tc-picker-value', !current && 'tc-muted'), children: shown }), _jsx("svg", { "aria-hidden": true, className: "tc-chevron", viewBox: "0 0 12 12", fill: "none", stroke: "currentColor", strokeWidth: "1.75", children: _jsx("path", { d: "M2.5 4.5 6 8l3.5-3.5", strokeLinecap: "round", strokeLinejoin: "round" }) })] }), open && (_jsxs("div", { ref: menu, popover: "auto", className: "tc-popover", onToggle: (e) => {
|
|
188
|
+
if (e.target === e.currentTarget && e.newState === 'closed')
|
|
189
|
+
setOpen(false);
|
|
190
|
+
}, children: [_jsx("input", { className: "tc-search", role: "combobox", "aria-expanded": true, "aria-controls": listId, "aria-activedescendant": options[at] ? `${listId}-${at}` : undefined, "aria-label": labels.search, placeholder: labels.search, value: query, onChange: (e) => {
|
|
150
191
|
setQuery(e.target.value);
|
|
151
192
|
setCursor(0);
|
|
152
|
-
}, onKeyDown: onKeyDown }), _jsxs("div", { id: listId, role: "listbox", "aria-label": label ?? labels.project, className: "tc-options", children: [options.length === 0 && _jsx("p", { className: "tc-empty", children: labels.noMatch }), options.map((o, i) => (_jsxs("div", { children: [o.group !== (options[i - 1]?.group ?? '') && _jsx("div", { className: "tc-group", children: o.group }), _jsxs("div", { id: `${listId}-${i}`, role: "option", tabIndex: -1, "aria-selected": o.id === value, className: cx('tc-option', i === at && 'tc-option-active'), onPointerMove: () => setCursor(i), onClick: () =>
|
|
193
|
+
}, onKeyDown: onKeyDown }), _jsxs("div", { id: listId, role: "listbox", "aria-label": label ?? labels.project, className: "tc-options", children: [options.length === 0 && _jsx("p", { className: "tc-empty", children: labels.noMatch }), options.map((o, i) => (_jsxs("div", { children: [o.group !== (options[i - 1]?.group ?? '') && _jsx("div", { className: "tc-group", children: o.group }), _jsxs("div", { id: `${listId}-${i}`, role: "option", tabIndex: -1, "aria-selected": o.id === value, className: cx('tc-option', i === at && 'tc-option-active'), onPointerMove: () => setCursor(i), onClick: (e) => {
|
|
194
|
+
// A picker can sit inside a field label. Selecting its row
|
|
195
|
+
// must not activate the labelled trigger and reopen the menu.
|
|
196
|
+
e.preventDefault();
|
|
197
|
+
pick(o.id);
|
|
198
|
+
}, children: [_jsx(ProjectDot, { projectId: o.id }), _jsx("span", { className: "tc-option-name", children: o.name }), o.id === value && (_jsx("svg", { "aria-hidden": true, className: "tc-check", viewBox: "0 0 12 12", fill: "none", stroke: "currentColor", strokeWidth: "2", children: _jsx("path", { d: "m2.5 6.5 2.5 2.5 4.5-5.5", strokeLinecap: "round", strokeLinejoin: "round" }) }))] })] }, o.id)))] })] }))] }));
|
|
153
199
|
}
|
|
154
200
|
const barLabels = {
|
|
155
201
|
...pickerLabels,
|
|
@@ -161,6 +207,9 @@ const barLabels = {
|
|
|
161
207
|
switchProject: 'Project the clock is running on',
|
|
162
208
|
locked: 'This pay period’s timesheet is submitted, so the clock is off.',
|
|
163
209
|
since: (time) => `Running since ${time}`,
|
|
210
|
+
missingDescription: 'Add a description to stop the clock and save this time entry.',
|
|
211
|
+
missingProject: 'Choose a project to stop the clock and save this time entry.',
|
|
212
|
+
missingDescriptionAndProject: 'Add a description and choose a project to stop the clock and save this time entry.',
|
|
164
213
|
};
|
|
165
214
|
/**
|
|
166
215
|
* The clock as a bar: say what you are working on, pick its project, and
|
|
@@ -175,10 +224,22 @@ export function ClockBar({ basePath, store, labels: given, className, onError, o
|
|
|
175
224
|
const { running } = clock;
|
|
176
225
|
const now = useSecond(!!running);
|
|
177
226
|
const bar = useRef(null);
|
|
227
|
+
const [stopAttempt, setStopAttempt] = useState(null);
|
|
228
|
+
const errorId = useId();
|
|
178
229
|
// Whether a press that began inside the bar is under way.
|
|
179
230
|
const pressed = useRef(false);
|
|
180
231
|
if (clock.status !== 'ready')
|
|
181
232
|
return null;
|
|
233
|
+
const missingDescription = clock.requireDescription && !clock.note.trim();
|
|
234
|
+
const missingProject = clock.requireProject && !clock.projectId;
|
|
235
|
+
const missing = missingDescription && missingProject
|
|
236
|
+
? labels.missingDescriptionAndProject
|
|
237
|
+
: missingDescription
|
|
238
|
+
? labels.missingDescription
|
|
239
|
+
: missingProject
|
|
240
|
+
? labels.missingProject
|
|
241
|
+
: null;
|
|
242
|
+
const stopError = running && stopAttempt === running.id ? missing : null;
|
|
182
243
|
const failed = (action) => (err) => onError?.(err, action);
|
|
183
244
|
const choose = (projectId) => {
|
|
184
245
|
clock
|
|
@@ -203,6 +264,11 @@ export function ClockBar({ basePath, store, labels: given, className, onError, o
|
|
|
203
264
|
: undefined;
|
|
204
265
|
return (_jsxs("form", { ref: bar, "aria-label": labels.clock, className: cx('tc-bar', className), onSubmit: (e) => {
|
|
205
266
|
e.preventDefault();
|
|
267
|
+
if (running && missing) {
|
|
268
|
+
setStopAttempt(running.id);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
setStopAttempt(null);
|
|
206
272
|
if (running)
|
|
207
273
|
clock.stop().then(onChanged, failed('stop'));
|
|
208
274
|
else
|
|
@@ -213,11 +279,11 @@ export function ClockBar({ basePath, store, labels: given, className, onError, o
|
|
|
213
279
|
pressed.current = false;
|
|
214
280
|
}, onPointerCancelCapture: () => {
|
|
215
281
|
pressed.current = false;
|
|
216
|
-
}, children: [_jsx("input", { className: "tc-note", value: clock.note, placeholder: clock.locked ? labels.locked : labels.notePlaceholder, "aria-label": labels.noteLabel, disabled: clock.locked, maxLength: 2000, onChange: (e) => clock.setNote(e.target.value), onBlur: running ? leaveNote : undefined, onKeyDown: (e) => {
|
|
282
|
+
}, children: [_jsx("input", { className: "tc-note", value: clock.note, placeholder: clock.locked ? labels.locked : labels.notePlaceholder, "aria-label": labels.noteLabel, disabled: clock.locked, "aria-required": !!running && clock.requireDescription, "aria-invalid": !!stopError && missingDescription, "aria-describedby": stopError && missingDescription ? errorId : undefined, maxLength: 2000, onChange: (e) => clock.setNote(e.target.value), onBlur: running ? leaveNote : undefined, onKeyDown: (e) => {
|
|
217
283
|
// While the clock runs, Enter is done with the note; it doesn't stop the clock.
|
|
218
284
|
if (running && e.key === 'Enter') {
|
|
219
285
|
e.preventDefault();
|
|
220
286
|
e.currentTarget.blur();
|
|
221
287
|
}
|
|
222
|
-
} }), _jsx(ProjectPicker, { className: "tc-bar-picker", projects: clock.projects, value: clock.projectId, onChange: choose, required: clock.requireProject, disabled: clock.locked || (!!running && clock.busy), label: running ? labels.switchProject : undefined, labels: labels }), running ? (_jsx("span", { className: "tc-time", role: "timer", title: since, children: stopwatch(now - Date.parse(running.startedAt)) })) : (_jsx("span", { "aria-hidden": true, className: "tc-time tc-time-idle", children: stopwatch(0) })), _jsx("button", { type: "submit", "aria-label": running ? labels.stop : labels.start, disabled: clock.locked || clock.busy, className: cx('tc-go', !!running && 'tc-go-stop'), children: _jsx("svg", { "aria-hidden": true, viewBox: "0 0 16 16", fill: "currentColor", children: running ? _jsx("rect", { x: "4", y: "4", width: "8", height: "8", rx: "1" }) : _jsx("path", { d: "M5.5 3.5v9l7-4.5z" }) }) })] }));
|
|
288
|
+
} }), _jsx(ProjectPicker, { className: "tc-bar-picker", projects: clock.projects, value: clock.projectId, onChange: choose, required: clock.requireProject, disabled: clock.locked || (!!running && clock.busy), label: running ? labels.switchProject : undefined, labels: labels }), running ? (_jsx("span", { className: "tc-time", role: "timer", title: since, children: stopwatch(now - Date.parse(running.startedAt)) })) : (_jsx("span", { "aria-hidden": true, className: "tc-time tc-time-idle", children: stopwatch(0) })), _jsx("button", { type: "submit", "aria-describedby": stopError ? errorId : undefined, "aria-label": running ? labels.stop : labels.start, disabled: clock.locked || clock.busy, className: cx('tc-go', !!running && 'tc-go-stop'), children: _jsx("svg", { "aria-hidden": true, viewBox: "0 0 16 16", fill: "currentColor", children: running ? _jsx("rect", { x: "4", y: "4", width: "8", height: "8", rx: "1" }) : _jsx("path", { d: "M5.5 3.5v9l7-4.5z" }) }) }), stopError && (_jsx("p", { id: errorId, role: "alert", className: "tc-stop-error", children: stopError }))] }));
|
|
223
289
|
}
|
package/dist/styles.css
CHANGED
|
@@ -97,6 +97,13 @@
|
|
|
97
97
|
cursor: not-allowed;
|
|
98
98
|
opacity: 0.5;
|
|
99
99
|
}
|
|
100
|
+
.tc-stop-error {
|
|
101
|
+
flex: 1 1 100%;
|
|
102
|
+
margin: 0;
|
|
103
|
+
padding: 0.25rem 0.75rem;
|
|
104
|
+
color: var(--tc-fg);
|
|
105
|
+
font-size: 0.875rem;
|
|
106
|
+
}
|
|
100
107
|
@media (min-width: 40rem) {
|
|
101
108
|
.tc-note {
|
|
102
109
|
flex-basis: 12rem;
|
|
@@ -155,13 +162,13 @@
|
|
|
155
162
|
color: var(--tc-accent);
|
|
156
163
|
}
|
|
157
164
|
.tc-popover {
|
|
158
|
-
position:
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
165
|
+
position: fixed;
|
|
166
|
+
inset: auto;
|
|
167
|
+
padding: 0;
|
|
168
|
+
color: var(--tc-fg);
|
|
162
169
|
width: 18rem;
|
|
163
170
|
max-width: calc(100vw - 2rem);
|
|
164
|
-
margin
|
|
171
|
+
margin: 0;
|
|
165
172
|
border: 1px solid var(--tc-border);
|
|
166
173
|
border-radius: 0.625rem;
|
|
167
174
|
background: var(--tc-bg);
|
|
@@ -169,7 +176,12 @@
|
|
|
169
176
|
0 10px 28px -10px rgb(0 0 0 / 0.3),
|
|
170
177
|
0 2px 6px -2px rgb(0 0 0 / 0.12);
|
|
171
178
|
}
|
|
179
|
+
.tc-popover:popover-open {
|
|
180
|
+
display: flex;
|
|
181
|
+
flex-direction: column;
|
|
182
|
+
}
|
|
172
183
|
.tc-search {
|
|
184
|
+
flex-shrink: 0;
|
|
173
185
|
width: 100%;
|
|
174
186
|
padding: 0.5rem 0.75rem;
|
|
175
187
|
border: 0;
|
|
@@ -185,6 +197,7 @@
|
|
|
185
197
|
color: var(--tc-muted);
|
|
186
198
|
}
|
|
187
199
|
.tc-options {
|
|
200
|
+
min-height: 0;
|
|
188
201
|
max-height: 16rem;
|
|
189
202
|
overflow-y: auto;
|
|
190
203
|
padding: 0.25rem;
|
package/package.json
CHANGED