Better task registration UX!

This commit is contained in:
2026-07-02 23:10:40 +01:00
parent 9d37177ca5
commit bf9f784a50
33 changed files with 1302 additions and 839 deletions
+54 -50
View File
@@ -1,22 +1,18 @@
'use server';
import { eq } from 'drizzle-orm';
import { eq, inArray } from 'drizzle-orm';
import { getRequestDb } from '@/db/request';
import { division, execution, person, task } from '@/db/schema';
import type { Division, Task } from '@/db/types';
import { getDict } from '@/i18n/server';
import { makeFormSchema, newId } from './form/schema';
import { makeCreateTaskSchema, makeRegisterSchema, newId } from './form/schema';
export async function registerExecution(rawValues: unknown) {
const fields = (await getDict()).add.form.fields;
const values = makeFormSchema({
person: fields.person.error,
division: fields.division.error,
task: fields.task.empty,
}).parse(rawValues);
const values = makeRegisterSchema({ person: fields.person.error, task: fields.task.empty }).parse(rawValues);
const db = await getRequestDb();
const executions = await db.transaction(async tx => {
// Resolve the person once (a new person is created a single time, not per task).
let personId = values.personId;
let personName: string;
if (personId === newId) {
@@ -28,59 +24,67 @@ export async function registerExecution(rawValues: unknown) {
personName = found.name;
}
// New division is created at most once, lazily, only if a new task needs it.
let divisionId = values.divisionId;
let divisionResolved = false;
const ensureDivisionId = async () => {
if (!divisionResolved) {
if (divisionId === newId) {
const [created] = await tx.insert(division).values(values.newDivision).returning({ id: division.id });
divisionId = created.id;
}
divisionResolved = true;
}
return divisionId;
};
const taskIds = [...new Set(values.executions.map(e => e.taskId))];
const tasks = await tx
.select({ id: task.id, name: task.name, xp: task.xp })
.from(task)
.where(inArray(task.id, taskIds));
const tasksById = new Map(tasks.map(t => [t.id, t]));
const out: { effort: number; person: { name: string }; task: { name: string; xp: number } }[] = [];
for (const taskId of values.taskIds) {
let realTaskId = taskId;
let taskName: string;
let taskXp: number;
if (taskId === newId) {
const [created] = await tx
.insert(task)
.values({ ...values.newTask, divisionId: await ensureDivisionId() })
.returning({ id: task.id, name: task.name, xp: task.xp });
realTaskId = created.id;
taskName = created.name;
taskXp = created.xp;
} else {
const [found] = await tx
.select({ name: task.name, xp: task.xp })
.from(task)
.where(eq(task.id, realTaskId))
.limit(1);
taskName = found.name;
taskXp = found.xp;
for (const entry of values.executions) {
const found = tasksById.get(entry.taskId);
if (!found) {
continue;
}
const [created] = await tx
.insert(execution)
.values({
taskId: realTaskId,
personId,
date: values.newExecution.date,
effort: values.newExecution.effort,
})
.values({ taskId: entry.taskId, personId, date: entry.date, effort: entry.effort })
.returning({ effort: execution.effort });
out.push({ effort: created.effort, person: { name: personName }, task: { name: taskName, xp: taskXp } });
out.push({ effort: created.effort, person: { name: personName }, task: { name: found.name, xp: found.xp } });
}
return out;
});
// No revalidatePath: dynamic routes re-render on navigation; the form's own
// router.refresh() (when a new person/division/task is added) updates the dropdowns.
return executions;
}
export type CreatedTask = Task & { division: Division };
export async function createTask(rawValues: unknown): Promise<CreatedTask> {
const fields = (await getDict()).add.form.fields;
const values = makeCreateTaskSchema({ division: fields.division.error, task: fields.newTask.name.error }).parse(
rawValues,
);
const db = await getRequestDb();
const created = await db.transaction(async tx => {
let resolvedDivision: Division;
if (values.divisionId === newId) {
[resolvedDivision] = await tx.insert(division).values(values.newDivision).returning();
} else {
[resolvedDivision] = await tx.select().from(division).where(eq(division.id, values.divisionId)).limit(1);
}
const [createdTask] = await tx
.insert(task)
.values({
name: values.name,
description: values.description || null,
n: values.n,
everyN: values.everyN,
everySpan: values.everySpan,
xp: values.xp,
divisionId: resolvedDivision.id,
})
.returning();
return { ...createdTask, division: resolvedDivision };
});
// No revalidatePath: dynamic routes refetch on navigation (see db/request.ts), and the caller
// adds the returned task to local state for immediate in-place use.
return created;
}
+168
View File
@@ -0,0 +1,168 @@
'use client';
import { Button, Drawer, SearchField, Separator } from '@heroui/react';
import { Plus } from 'lucide-react';
import { Fragment, type ReactNode, useMemo, useState } from 'react';
import type { Division } from '@/db/types';
import { tReplacer } from '@/i18n/t';
import { CreateTaskForm } from './create-task-form';
import { TaskOption } from './task-option';
import type { FormI18n, TaskWithDivision } from './types';
type Props = {
tasks: TaskWithDivision[];
divisions: Division[];
usedTaskIds: Set<string>;
i18n: FormI18n;
onAdd: (taskIds: string[]) => void;
onRemove: (taskIds: string[]) => void;
onTaskCreated: (task: TaskWithDivision) => void;
children: ReactNode;
};
// Group task rows by name so a chore that exists in several rooms shows once, with a chip per room.
const groupByName = (tasks: TaskWithDivision[]) => {
const groups = new Map<string, TaskWithDivision[]>();
for (const task of tasks) {
const bucket = groups.get(task.name);
if (bucket) {
bucket.push(task);
} else {
groups.set(task.name, [task]);
}
}
return [...groups.entries()];
};
export function AddTaskDrawer({
tasks,
divisions,
usedTaskIds,
i18n,
onAdd,
onRemove,
onTaskCreated,
children,
}: Props) {
const [search, setSearch] = useState('');
// Selection reflects the desired final basket: seeded from what's already added, so untouched
// rows stay put and toggling an added task off marks it for removal.
const [selected, setSelected] = useState<Set<string>>(new Set());
const [creating, setCreating] = useState(false);
const groups = useMemo(() => {
const term = search.trim().toLowerCase();
const filtered = term
? tasks.filter(t => t.name.toLowerCase().includes(term) || t.division.name.toLowerCase().includes(term))
: tasks;
return groupByName(filtered);
}, [tasks, search]);
const addIds = [...selected].filter(id => !usedTaskIds.has(id));
const removeIds = [...usedTaskIds].filter(id => !selected.has(id));
const toggle = (taskId: string) =>
setSelected(prev => {
const next = new Set(prev);
if (next.has(taskId)) {
next.delete(taskId);
} else {
next.add(taskId);
}
return next;
});
const submitLabel = removeIds.length
? addIds.length
? tReplacer(i18n.actions.addRemoveCount, { add: String(addIds.length), remove: String(removeIds.length) })
: tReplacer(i18n.actions.removeCount, { count: String(removeIds.length) })
: tReplacer(i18n.actions.addCount[addIds.length === 1 ? 'singular' : 'plural'], { count: String(addIds.length) });
return (
<Drawer
onOpenChange={isOpen => {
setSearch('');
setCreating(false);
setSelected(isOpen ? new Set(usedTaskIds) : new Set());
}}
>
{children}
<Drawer.Backdrop>
<Drawer.Content placement='bottom'>
<Drawer.Dialog className='flex max-h-[85dvh] flex-col'>
{({ close }: { close: () => void }) => (
<>
<Drawer.Header>
<Drawer.Heading>{i18n.actions.addTask}</Drawer.Heading>
</Drawer.Header>
<Drawer.Body className='flex-1 space-y-4 overflow-y-auto'>
{!creating && (
<>
<SearchField aria-label={i18n.fields.task.label} value={search} onChange={setSearch}>
<SearchField.Group>
<SearchField.SearchIcon />
<SearchField.Input placeholder={i18n.fields.task.search} />
<SearchField.ClearButton />
</SearchField.Group>
</SearchField>
{groups.length === 0 && (
<p className='py-4 text-center text-muted-foreground text-sm'>{i18n.fields.task.notFound}</p>
)}
<div className='space-y-3'>
{groups.map(([name, rooms], index) => (
<Fragment key={name}>
{index > 0 && <Separator />}
<TaskOption name={name} rooms={rooms} selected={selected} onToggle={toggle} />
</Fragment>
))}
</div>
<Separator />
<Button variant='ghost' fullWidth onPress={() => setCreating(true)}>
<Plus className='mr-2 h-4 w-4' />
{i18n.actions.create}
</Button>
</>
)}
{creating && (
<CreateTaskForm
divisions={divisions}
i18n={i18n}
onCancel={() => setCreating(false)}
onCreated={task => {
onTaskCreated(task);
onAdd([task.id]);
close();
}}
/>
)}
</Drawer.Body>
{!creating && (
<Drawer.Footer>
<Button
fullWidth
isDisabled={addIds.length === 0 && removeIds.length === 0}
onPress={() => {
onAdd(addIds);
onRemove(removeIds);
close();
}}
>
{submitLabel}
</Button>
</Drawer.Footer>
)}
</>
)}
</Drawer.Dialog>
</Drawer.Content>
</Drawer.Backdrop>
</Drawer>
);
}
+165
View File
@@ -0,0 +1,165 @@
'use client';
import { Button, FieldError, Input, Label, ListBox, Select, TextArea, TextField, toast } from '@heroui/react';
import { zodResolver } from '@hookform/resolvers/zod';
import { ChevronDown, Loader2, Plus } from 'lucide-react';
import { Controller, useForm } from 'react-hook-form';
import { InputNumber } from '@/components/ui/input-number';
import type { Division } from '@/db/types';
import { Span } from '@/lib/span-enum';
import { createTask } from '../actions';
import { FrequencyField } from './frequency-field';
import { type CreateTaskSchema, makeCreateTaskSchema, newId } from './schema';
import type { FormI18n, TaskWithDivision } from './types';
type Props = {
divisions: Division[];
i18n: FormI18n;
onCancel: () => void;
onCreated: (task: TaskWithDivision) => void;
};
export function CreateTaskForm({ divisions, i18n, onCancel, onCreated }: Props) {
const form = useForm<CreateTaskSchema>({
resolver: zodResolver(
makeCreateTaskSchema({ division: i18n.fields.division.error, task: i18n.fields.newTask.name.error }),
),
defaultValues: {
divisionId: '',
newDivision: { name: '' },
name: '',
description: '',
n: 1,
everyN: 2,
everySpan: Span.DAY,
xp: 1,
},
});
const divisionId = form.watch('divisionId');
const onSubmit = form.handleSubmit(async values => {
try {
onCreated(await createTask(values));
} catch {
toast.danger(i18n.messages.error);
}
});
return (
<div className='space-y-4'>
<Controller
name='divisionId'
control={form.control}
render={({ field, fieldState }) => (
<Select
aria-label={i18n.fields.division.label}
value={field.value || null}
onChange={key => field.onChange(key ? String(key) : '')}
isInvalid={!!fieldState.error}
>
<Label>{i18n.fields.division.label}</Label>
<Select.Trigger>
<Select.Value>
{({ defaultChildren }) => defaultChildren ?? i18n.fields.division.placeholder}
</Select.Value>
<Select.Indicator>
<ChevronDown className='h-4 w-4 opacity-50' />
</Select.Indicator>
</Select.Trigger>
<Select.Popover>
<ListBox>
{divisions.map(({ id, name }) => (
<ListBox.Item key={id} id={id} textValue={name}>
{name}
<ListBox.ItemIndicator />
</ListBox.Item>
))}
<ListBox.Item id={newId} textValue={i18n.fields.division.new}>
<span className='flex items-center gap-1'>
<Plus className='h-4 w-4' /> {i18n.fields.division.new}
</span>
<ListBox.ItemIndicator />
</ListBox.Item>
</ListBox>
</Select.Popover>
{fieldState.error && <FieldError>{fieldState.error.message}</FieldError>}
</Select>
)}
/>
{divisionId === newId && (
<Controller
name='newDivision.name'
control={form.control}
render={({ field }) => (
<TextField name={field.name} value={field.value ?? ''} onChange={field.onChange} onBlur={field.onBlur}>
<Label>{i18n.fields.newDivision.name.label}</Label>
<Input ref={field.ref} placeholder={i18n.fields.newDivision.name.placeholder} />
</TextField>
)}
/>
)}
<Controller
name='name'
control={form.control}
render={({ field, fieldState }) => (
<TextField
name={field.name}
value={field.value ?? ''}
onChange={field.onChange}
onBlur={field.onBlur}
isInvalid={!!fieldState.error}
>
<Label>{i18n.fields.newTask.name.label}</Label>
<Input ref={field.ref} placeholder={i18n.fields.newTask.name.placeholder} />
{fieldState.error && <FieldError>{fieldState.error.message}</FieldError>}
</TextField>
)}
/>
<Controller
name='description'
control={form.control}
render={({ field }) => (
<TextField name={field.name} value={field.value ?? ''} onChange={field.onChange} onBlur={field.onBlur}>
<Label>{i18n.fields.newTask.description.label}</Label>
<TextArea ref={field.ref} placeholder={i18n.fields.newTask.description.placeholder} />
</TextField>
)}
/>
<FrequencyField control={form.control} i18n={i18n} />
<Controller
name='xp'
control={form.control}
render={({ field }) => (
<div className='space-y-2'>
<p className='font-medium text-sm'>{i18n.fields.newTask.xp.label}</p>
<InputNumber
aria-label={i18n.fields.newTask.xp.label}
className='max-w-[120px]'
value={field.value}
onChange={field.onChange}
min={1}
max={64}
/>
<p className='text-muted-foreground text-xs'>{i18n.fields.newTask.xp.description}</p>
</div>
)}
/>
<div className='flex gap-2'>
<Button type='button' variant='outline' fullWidth onPress={onCancel}>
{i18n.actions.cancel}
</Button>
<Button type='button' fullWidth isDisabled={form.formState.isSubmitting} onPress={() => onSubmit()}>
{form.formState.isSubmitting && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{i18n.actions.create}
</Button>
</div>
</div>
);
}
+89
View File
@@ -0,0 +1,89 @@
'use client';
import { Button, Calendar, Popover } from '@heroui/react';
import { CalendarDate, getLocalTimeZone, parseDate, today } from '@internationalized/date';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { addDays, dateFnsLocale, format, isToday, isYesterday, subDays } from '@/lib/date-fns';
import type { FormI18n } from './types';
const toCalendarDate = (date: Date) => new CalendarDate(date.getFullYear(), date.getMonth() + 1, date.getDate());
const withDay = (date: Date, year: number, month: number, day: number) => {
const next = new Date(date);
next.setFullYear(year, month - 1, day);
return next;
};
type Props = {
value: Date;
onChange: (date: Date) => void;
i18n: FormI18n;
};
export function DateStepper({ value, onChange, i18n }: Props) {
const { i18n: i18next } = useTranslation();
const df = dateFnsLocale(i18next.language);
const atToday = isToday(value);
const label = isToday(value)
? i18n.labels.today
: isYesterday(value)
? i18n.labels.yesterday
: format(value, 'PPP', { locale: df });
return (
<div className='space-y-2'>
<p className='font-medium text-sm'>{i18n.fields.session.date}</p>
<div className='flex items-center gap-2'>
<Button
variant='outline'
isIconOnly
aria-label={i18n.fields.session.previousDay}
onPress={() => onChange(subDays(value, 1))}
>
<ChevronLeft className='h-4 w-4' />
</Button>
<Popover>
<Popover.Trigger>
<Button variant='outline' className='flex-1 font-normal'>
{label}
</Button>
</Popover.Trigger>
<Popover.Content className='w-auto p-0'>
<Popover.Dialog>
<Calendar
aria-label={i18n.fields.session.date}
value={toCalendarDate(value)}
onChange={next => next && onChange(withDay(value, next.year, next.month, next.day))}
minValue={parseDate('2020-01-01')}
maxValue={today(getLocalTimeZone())}
>
<Calendar.Header>
<Calendar.NavButton slot='previous' />
<Calendar.Heading />
<Calendar.NavButton slot='next' />
</Calendar.Header>
<Calendar.Grid>
<Calendar.GridHeader>{day => <Calendar.HeaderCell>{day}</Calendar.HeaderCell>}</Calendar.GridHeader>
<Calendar.GridBody>{date => <Calendar.Cell date={date} />}</Calendar.GridBody>
</Calendar.Grid>
</Calendar>
</Popover.Dialog>
</Popover.Content>
</Popover>
<Button
variant='outline'
isIconOnly
aria-label={i18n.fields.session.nextDay}
isDisabled={atToday}
onPress={() => !atToday && onChange(addDays(value, 1))}
>
<ChevronRight className='h-4 w-4' />
</Button>
</div>
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
.chip {
cursor: pointer;
border-color: hsl(var(--chip-hue) 10% 50%);
background-color: hsl(var(--chip-hue) 25% 50% / 0.04);
color: hsl(var(--chip-hue) 12% 72%);
}
.chip:hover {
border-color: hsl(var(--chip-hue) 20% 50%);
background-color: hsl(var(--chip-hue) 30% 50% / 0.1);
color: hsl(var(--chip-hue) 25% 75%);
}
.selected {
border-color: transparent;
background-color: hsl(var(--chip-hue) 100% 40%);
}
.selected:hover {
border-color: transparent;
background-color: hsl(var(--chip-hue) 100% 35%);
}
-95
View File
@@ -1,95 +0,0 @@
'use client';
import { Description, FieldError, Input, Label, ListBox, Select, Separator, TextField } from '@heroui/react';
import { ChevronDown, Plus } from 'lucide-react';
import { Controller, useFormContext } from 'react-hook-form';
import type { Division } from '@/db/types';
import type { I18N } from '@/i18n';
import { type FormSchema, newId } from './schema';
type Props = {
i18n: Pick<I18N['add']['form']['fields'], 'division' | 'newDivision'>;
divisions: Division[];
};
export function DivisionClient({ i18n, divisions }: Props) {
const form = useFormContext<FormSchema>();
const divisionId = form.watch('divisionId');
return (
<>
<Controller
name='divisionId'
control={form.control}
render={({ field, fieldState }) => (
<Select
aria-label={i18n.division.label}
value={field.value || null}
onChange={key => {
form.resetField('taskIds');
field.onChange(key ? String(key) : '');
}}
isInvalid={!!fieldState.error}
>
<Label>{i18n.division.label}</Label>
<Select.Trigger>
<Select.Value>{({ defaultChildren }) => defaultChildren ?? i18n.division.placeholder}</Select.Value>
<Select.Indicator>
<ChevronDown className='h-4 w-4 opacity-50' />
</Select.Indicator>
</Select.Trigger>
<Select.Popover>
<ListBox>
{divisions.map(({ id, name }) => (
<ListBox.Item key={id} id={id} textValue={name}>
{name}
<ListBox.ItemIndicator />
</ListBox.Item>
))}
<ListBox.Item id={newId} textValue={i18n.division.new}>
<span className='flex items-center gap-1'>
<Plus className='h-4 w-4' /> {i18n.division.new}
</span>
<ListBox.ItemIndicator />
</ListBox.Item>
</ListBox>
</Select.Popover>
{fieldState.error ? (
<FieldError>{fieldState.error.message}</FieldError>
) : (
<Description>{i18n.division.description}</Description>
)}
</Select>
)}
/>
{divisionId === newId && (
<>
<Controller
name='newDivision.name'
control={form.control}
render={({ field, fieldState }) => (
<TextField
name={field.name}
value={field.value ?? ''}
onChange={field.onChange}
onBlur={field.onBlur}
isInvalid={!!fieldState.error}
>
<Label>{i18n.newDivision.name.label}</Label>
<Input ref={field.ref} placeholder={i18n.newDivision.name.placeholder} />
{fieldState.error ? (
<FieldError>{fieldState.error.message}</FieldError>
) : (
<Description>{i18n.newDivision.name.description}</Description>
)}
</TextField>
)}
/>
<Separator />
</>
)}
</>
);
}
-11
View File
@@ -1,11 +0,0 @@
import pick from 'lodash.pick';
import { getRequestDb } from '@/db/request';
import { division } from '@/db/schema';
import { getDict } from '@/i18n/server';
import { DivisionClient } from './division-client';
export async function Division() {
const [db, dict] = [await getRequestDb(), await getDict()];
const divisions = await db.select().from(division);
return <DivisionClient divisions={divisions} i18n={pick(dict.add.form.fields, ['division', 'newDivision'])} />;
}
-181
View File
@@ -1,181 +0,0 @@
'use client';
import { Button, Calendar, ListBox, Popover, Select, Slider } from '@heroui/react';
import { CalendarDate, getLocalTimeZone, parseDate, today } from '@internationalized/date';
import { CalendarIcon, ChevronDown, Clock } from 'lucide-react';
import { Controller, useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import type { I18N } from '@/i18n';
import { dateFnsLocale, format } from '@/lib/date-fns';
import { cn } from '@/lib/utils';
import type { FormSchema } from './schema';
type Props = {
i18n: I18N['add']['form']['fields']['newExecution'];
};
const toCalendarDate = (date: Date) => new CalendarDate(date.getFullYear(), date.getMonth() + 1, date.getDate());
export function ExecutionClient({ i18n }: Props) {
const form = useFormContext<FormSchema>();
const { i18n: i18next } = useTranslation();
const df = dateFnsLocale(i18next.language);
return (
<>
<Controller
name='newExecution.date'
control={form.control}
render={({ field }) => (
<div className='grid gap-4 sm:grid-cols-2'>
<div className='flex flex-col gap-2'>
<p className='font-medium text-sm'>{i18n.date.label}</p>
<Popover>
<Popover.Trigger>
<Button
variant='outline'
className={cn('w-full justify-start pl-3 font-normal', !field.value && 'text-muted-foreground')}
>
{field.value ? format(field.value, 'PPP', { locale: df }) : <span>{i18n.date.placeholder}</span>}
<CalendarIcon className='ml-auto h-4 w-4 opacity-50' />
</Button>
</Popover.Trigger>
<Popover.Content className='w-auto p-0'>
<Popover.Dialog>
<Calendar
aria-label={i18n.date.label}
value={toCalendarDate(field.value)}
onChange={value => {
if (!value) {
return;
}
const next = new Date(field.value);
next.setFullYear(value.year, value.month - 1, value.day);
field.onChange(next);
}}
minValue={parseDate('2020-01-01')}
maxValue={today(getLocalTimeZone())}
>
<Calendar.Header>
<Calendar.NavButton slot='previous' />
<Calendar.Heading />
<Calendar.NavButton slot='next' />
</Calendar.Header>
<Calendar.Grid>
<Calendar.GridHeader>
{day => <Calendar.HeaderCell>{day}</Calendar.HeaderCell>}
</Calendar.GridHeader>
<Calendar.GridBody>{date => <Calendar.Cell date={date} />}</Calendar.GridBody>
</Calendar.Grid>
</Calendar>
</Popover.Dialog>
</Popover.Content>
</Popover>
<p className='text-muted-foreground text-xs'>{i18n.date.description}</p>
</div>
<div className='space-y-2'>
<p className='font-medium text-sm'>{i18n.time.label}</p>
<span className='grid grid-cols-[1fr_max-content_1fr_max-content] items-center gap-4'>
<Select
aria-label={i18n.time.hours}
value={String(field.value.getHours() + 1)}
onChange={key => {
const next = new Date(field.value);
next.setHours(Number(key) - 1);
field.onChange(next);
}}
>
<Select.Trigger>
<Select.Value />
<Select.Indicator>
<ChevronDown className='h-4 w-4 opacity-50' />
</Select.Indicator>
</Select.Trigger>
<Select.Popover>
<ListBox>
{Array.from({ length: 24 }).map((_, index) => (
<ListBox.Item
// biome-ignore lint/suspicious/noArrayIndexKey: fixed-length time picker
key={index}
id={String(index + 1)}
textValue={String(index).padStart(2, '0')}
>
{String(index).padStart(2, '0')}
<ListBox.ItemIndicator />
</ListBox.Item>
))}
</ListBox>
</Select.Popover>
</Select>
{':'}
<Select
aria-label={i18n.time.minutes}
value={String((Math.round(field.value.getMinutes() / 15) % 4) + 1)}
onChange={key => {
const next = new Date(field.value);
next.setMinutes((Number(key) - 1) * 15);
field.onChange(next);
}}
>
<Select.Trigger>
<Select.Value />
<Select.Indicator>
<ChevronDown className='h-4 w-4 opacity-50' />
</Select.Indicator>
</Select.Trigger>
<Select.Popover>
<ListBox>
{Array.from({ length: 4 }).map((_, index) => (
<ListBox.Item
// biome-ignore lint/suspicious/noArrayIndexKey: fixed-length time picker
key={index}
id={String(index + 1)}
textValue={String(index * 15).padStart(2, '0')}
>
{String(index * 15).padStart(2, '0')}
<ListBox.ItemIndicator />
</ListBox.Item>
))}
</ListBox>
</Select.Popover>
</Select>
<Clock className='opacity-50' />
</span>
<p className='text-muted-foreground text-xs'>{i18n.time.description}</p>
</div>
</div>
)}
/>
<Controller
name='newExecution.effort'
control={form.control}
render={({ field: { value, onChange } }) => (
<div className='space-y-2'>
<p className='font-medium text-sm'>{i18n.effort.label}</p>
<div className='grid grid-cols-[64px_1fr] items-center gap-3'>
<span className='h-10 rounded-md border border-input bg-background px-3 py-2 text-center text-sm'>
{`${value.toFixed(2)}x`}
</span>
<Slider
aria-label={i18n.effort.label}
minValue={0.25}
maxValue={2}
step={0.01}
value={value}
onChange={next => onChange(Array.isArray(next) ? next[0] : next)}
>
<Slider.Track>
<Slider.Fill />
<Slider.Thumb />
</Slider.Track>
</Slider>
</div>
<p className='text-muted-foreground text-xs'>{i18n.effort.description}</p>
</div>
)}
/>
</>
);
}
-7
View File
@@ -1,7 +0,0 @@
import { getDict } from '@/i18n/server';
import { ExecutionClient } from './execution-client';
export async function Execution() {
const dict = await getDict();
return <ExecutionClient i18n={dict.add.form.fields.newExecution} />;
}
+89
View File
@@ -0,0 +1,89 @@
'use client';
import { ListBox, Select } from '@heroui/react';
import { ChevronDown } from 'lucide-react';
import { type Control, Controller, useWatch } from 'react-hook-form';
import { InputNumber } from '@/components/ui/input-number';
import { Span } from '@/lib/span-enum';
import type { CreateTaskSchema } from './schema';
import type { FormI18n } from './types';
const spans = [Span.HOUR, Span.DAY, Span.WEEK, Span.MONTH, Span.YEAR, Span.DECADE, Span.CENTURY];
type Props = {
control: Control<CreateTaskSchema>;
i18n: FormI18n;
};
export function FrequencyField({ control, i18n }: Props) {
const n = useWatch({ control, name: 'n' });
const everyN = useWatch({ control, name: 'everyN' });
return (
<div className='space-y-2'>
<p className='font-medium text-sm'>{i18n.fields.newTask.frequency.label}</p>
<span className='flex items-center gap-2'>
<Controller
name='n'
control={control}
render={({ field }) => (
<InputNumber
aria-label={i18n.labels.time.plural}
className='max-w-[120px]'
value={field.value}
onChange={field.onChange}
min={1}
max={32}
/>
)}
/>
{i18n.labels.time[Number(n) === 1 ? 'singular' : 'plural']}
</span>
<div className='grid grid-cols-[max-content_max-content_3fr] items-center gap-2'>
<span className='w-max'>{i18n.labels.every}</span>
<Controller
name='everyN'
control={control}
render={({ field }) => (
<InputNumber
aria-label={i18n.labels.every}
className='max-w-[120px]'
value={field.value}
onChange={field.onChange}
min={1}
max={32}
/>
)}
/>
<Controller
name='everySpan'
control={control}
render={({ field }) => (
<Select
aria-label={i18n.fields.newTask.frequency.label}
value={field.value}
onChange={key => field.onChange(String(key))}
>
<Select.Trigger>
<Select.Value />
<Select.Indicator>
<ChevronDown className='h-4 w-4 opacity-50' />
</Select.Indicator>
</Select.Trigger>
<Select.Popover>
<ListBox>
{spans.map(span => (
<ListBox.Item key={span} id={span} textValue={i18n.labels.span[span].singular}>
{i18n.labels.span[span][Number(everyN) === 1 ? 'singular' : 'plural']}
<ListBox.ItemIndicator />
</ListBox.Item>
))}
</ListBox>
</Select.Popover>
</Select>
)}
/>
</div>
</div>
);
}
+84 -45
View File
@@ -1,61 +1,92 @@
'use client';
import { useAutoAnimate } from '@formkit/auto-animate/react';
import { Button, toast } from '@heroui/react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Loader2 } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { type ReactNode, useMemo } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import type { I18N } from '@/i18n';
import { applyTimeOfDay, timeOfDayFromHours } from '@/config/time-of-day';
import type { Division, Person } from '@/db/types';
import { tReplacer } from '@/i18n/t';
import { Span } from '@/lib/span-enum';
import { registerExecution } from '../actions';
import { type FormErrors, type FormSchema, makeFormSchema, newId } from './schema';
import { makeSessionSchema, newId, type SessionSchema } from './schema';
import { SessionHeader } from './session-header';
import { TaskBasket } from './task-basket';
import type { FormI18n, TaskWithDivision } from './types';
type Props = {
sections: ReactNode[];
i18n: Pick<I18N['add']['form'], 'messages' | 'actions'> & { and: string };
errors: FormErrors;
prefill?: { divisionId?: string; taskId?: string };
people: Person[];
divisions: Division[];
tasks: TaskWithDivision[];
defaultPersonId?: string;
prefillTaskId?: string;
returnTo?: string;
i18n: FormI18n;
};
export function AddForm({ sections, i18n, errors, prefill, returnTo }: Props) {
const [ref] = useAutoAnimate();
const router = useRouter();
const now = useMemo(() => new Date(), []);
const { person, division, task } = errors;
const resolver = useMemo(() => zodResolver(makeFormSchema({ person, division, task })), [person, division, task]);
// Midday keeps the session date's day component stable across time-of-day shifts and TZ math.
const startOfToday = () => {
const now = new Date();
now.setHours(12, 0, 0, 0);
return now;
};
const form = useForm<FormSchema>({
export function AddForm({ people, divisions, tasks, defaultPersonId, prefillTaskId, returnTo, i18n }: Props) {
const router = useRouter();
// Tasks created inline during this session, merged with the server-provided list. Deduped by id
// so a router.refresh() (on new-person submit) that surfaces a just-created task can't double it.
const [createdTasks, setCreatedTasks] = useState<TaskWithDivision[]>([]);
const allTasks = useMemo(() => {
const byId = new Map<string, TaskWithDivision>();
for (const task of [...createdTasks, ...tasks]) {
byId.set(task.id, task);
}
return [...byId.values()];
}, [createdTasks, tasks]);
const resolver = useMemo(
() => zodResolver(makeSessionSchema({ person: i18n.fields.person.error, task: i18n.fields.task.empty })),
[i18n.fields.person.error, i18n.fields.task.empty],
);
const form = useForm<SessionSchema>({
resolver,
defaultValues: {
divisionId: prefill?.divisionId ?? '',
newDivision: {
name: '',
},
personId: '',
newPerson: {
name: '',
},
taskIds: prefill?.taskId ? [prefill.taskId] : [],
newTask: {
name: '',
n: 1,
everyN: 2,
everySpan: Span.DAY,
xp: 1,
},
newExecution: {
date: now,
effort: 1,
},
personId: defaultPersonId ?? '',
newPerson: { name: '' },
date: startOfToday(),
lines: prefillTaskId
? [{ taskId: prefillTaskId, timeOfDay: timeOfDayFromHours(new Date().getHours()), effort: 1 }]
: [],
},
});
async function onSubmit(values: FormSchema) {
const executions = await registerExecution(values);
const onTaskCreated = useCallback((task: TaskWithDivision) => setCreatedTasks(prev => [task, ...prev]), []);
async function onSubmit(values: SessionSchema) {
let executions: Awaited<ReturnType<typeof registerExecution>>;
try {
executions = await registerExecution({
personId: values.personId,
newPerson: values.newPerson,
executions: values.lines.map(line => ({
taskId: line.taskId,
date: applyTimeOfDay(values.date, line.timeOfDay),
effort: line.effort,
})),
});
} catch {
toast.danger(i18n.messages.error);
return;
}
// Every line's task was gone by submit time (retired/deleted since page load): nothing recorded.
if (!executions.length) {
toast.danger(i18n.messages.error);
return;
}
const taskNames = executions.map(({ task }) => `"${task.name}"`);
const lastTaskName = taskNames.pop();
@@ -63,7 +94,7 @@ export function AddForm({ sections, i18n, errors, prefill, returnTo }: Props) {
toast.success(i18n.messages.success.title, {
description: tReplacer(i18n.messages.success.description[executions.length === 1 ? 'singular' : 'plural'], {
person: executions[0].person.name,
task: [...(taskNames.length ? [taskNames.join(', ')] : []), lastTaskName].join(` ${i18n.and} `),
task: [...(taskNames.length ? [taskNames.join(', ')] : []), lastTaskName].join(` ${i18n.labels.and} `),
xp: String(Math.round(executions.reduce((total, { task, effort }) => total + task.xp * effort, 0))),
}),
});
@@ -73,21 +104,29 @@ export function AddForm({ sections, i18n, errors, prefill, returnTo }: Props) {
return;
}
if ([values.divisionId, values.personId, ...values.taskIds].includes(newId)) {
if (values.personId === newId) {
router.refresh();
}
form.reset();
form.reset({ personId: values.personId, newPerson: { name: '' }, date: startOfToday(), lines: [] });
}
const lineCount = form.watch('lines').length;
return (
<FormProvider {...form}>
<form ref={ref} onSubmit={form.handleSubmit(onSubmit)} className='w-full space-y-8'>
{sections}
<form onSubmit={form.handleSubmit(onSubmit)} className='w-full space-y-8'>
<SessionHeader people={people} defaultPersonId={defaultPersonId} i18n={i18n} />
<Button type='submit' fullWidth className='relative' isDisabled={form.formState.isSubmitting}>
<TaskBasket tasks={allTasks} divisions={divisions} i18n={i18n} onTaskCreated={onTaskCreated} />
<Button type='submit' fullWidth className='relative' isDisabled={form.formState.isSubmitting || !lineCount}>
{form.formState.isSubmitting && <Loader2 className='absolute right-6 h-4 w-4 animate-spin' />}
{i18n.actions.register}
{lineCount
? tReplacer(i18n.actions.registerCount[lineCount === 1 ? 'singular' : 'plural'], {
count: String(lineCount),
})
: i18n.actions.register}
</Button>
</form>
</FormProvider>
-84
View File
@@ -1,84 +0,0 @@
'use client';
import { Description, FieldError, Input, Label, Radio, RadioGroup, Separator, TextField } from '@heroui/react';
import { useEffect } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import type { Person } from '@/db/types';
import type { I18N } from '@/i18n';
import { type FormSchema, newId } from './schema';
type Props = {
i18n: Pick<I18N['add']['form']['fields'], 'person' | 'newPerson'>;
people: Person[];
defaultPersonId?: string;
};
export function PersonClient({ i18n, people, defaultPersonId }: Props) {
const form = useFormContext<FormSchema>();
const personId = form.watch('personId');
// Default to the logged-in user's person (when authenticated), still overridable.
useEffect(() => {
if (defaultPersonId && !form.getValues('personId')) {
form.setValue('personId', defaultPersonId);
}
}, [defaultPersonId, form]);
return (
<>
<Controller
name='personId'
control={form.control}
render={({ field, fieldState }) => (
<RadioGroup
aria-label={i18n.person.label}
value={field.value}
onChange={field.onChange}
isInvalid={!!fieldState.error}
>
<Label>{i18n.person.label}</Label>
{[...people, { id: newId, name: i18n.person.new }].map(({ id, name }) => (
<Radio key={id} value={id}>
<Radio.Content>
<Radio.Control>
<Radio.Indicator />
</Radio.Control>
{name}
</Radio.Content>
</Radio>
))}
{fieldState.error && <FieldError>{fieldState.error.message}</FieldError>}
</RadioGroup>
)}
/>
{personId === newId && (
<>
<Controller
name='newPerson.name'
control={form.control}
render={({ field, fieldState }) => (
<TextField
name={field.name}
value={field.value ?? ''}
onChange={field.onChange}
onBlur={field.onBlur}
isInvalid={!!fieldState.error}
>
<Label>{i18n.newPerson.name.label}</Label>
<Input ref={field.ref} />
{fieldState.error ? (
<FieldError>{fieldState.error.message}</FieldError>
) : (
<Description>{i18n.newPerson.name.description}</Description>
)}
</TextField>
)}
/>
<Separator />
</>
)}
</>
);
}
+81
View File
@@ -0,0 +1,81 @@
'use client';
import { Description, FieldError, Input, Label, Radio, RadioGroup, TextField } from '@heroui/react';
import { useEffect } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import type { Person } from '@/db/types';
import { newId, type SessionSchema } from './schema';
import type { FormI18n } from './types';
type Props = {
people: Person[];
defaultPersonId?: string;
i18n: FormI18n;
};
export function PersonPicker({ people, defaultPersonId, i18n }: Props) {
const form = useFormContext<SessionSchema>();
const personId = form.watch('personId');
useEffect(() => {
if (defaultPersonId && !form.getValues('personId')) {
form.setValue('personId', defaultPersonId);
}
}, [defaultPersonId, form]);
return (
<>
<Controller
name='personId'
control={form.control}
render={({ field, fieldState }) => (
<RadioGroup
aria-label={i18n.fields.session.who}
value={field.value}
onChange={field.onChange}
isInvalid={!!fieldState.error}
orientation='horizontal'
className='flex-wrap gap-x-4 gap-y-2'
>
<Label>{i18n.fields.session.who}</Label>
{[...people, { id: newId, name: i18n.fields.person.new }].map(({ id, name }) => (
<Radio key={id} value={id}>
<Radio.Content>
<Radio.Control>
<Radio.Indicator />
</Radio.Control>
{name}
</Radio.Content>
</Radio>
))}
{fieldState.error && <FieldError>{fieldState.error.message}</FieldError>}
</RadioGroup>
)}
/>
{personId === newId && (
<Controller
name='newPerson.name'
control={form.control}
render={({ field, fieldState }) => (
<TextField
name={field.name}
value={field.value ?? ''}
onChange={field.onChange}
onBlur={field.onBlur}
isInvalid={!!fieldState.error}
>
<Label>{i18n.fields.newPerson.name.label}</Label>
<Input ref={field.ref} />
{fieldState.error ? (
<FieldError>{fieldState.error.message}</FieldError>
) : (
<Description>{i18n.fields.newPerson.name.description}</Description>
)}
</TextField>
)}
/>
)}
</>
);
}
-18
View File
@@ -1,18 +0,0 @@
import pick from 'lodash.pick';
import { getRequestContext } from '@/db/request';
import { person } from '@/db/schema';
import { getDict } from '@/i18n/server';
import { PersonClient } from './person-client';
export async function Person() {
const { db, user } = await getRequestContext();
const dict = await getDict();
const people = await db.select().from(person);
return (
<PersonClient
people={people}
defaultPersonId={user?.personId}
i18n={pick(dict.add.form.fields, ['person', 'newPerson'])}
/>
);
}
+39 -17
View File
@@ -1,28 +1,50 @@
import * as z from 'zod';
import { TimeOfDay } from '@/config/time-of-day';
import { Span } from '@/lib/span-enum';
export const newId = 'new';
export type FormErrors = { person: string; division: string; task: string };
export type FormErrors = { person: string; task: string };
export const makeFormSchema = (errors: FormErrors) =>
// New tasks and divisions are created up front (see createTask), so a line always references a real
// task id, never a placeholder: the whole session then commits atomically.
const lineSchema = z.object({
taskId: z.string().uuid(),
timeOfDay: z.nativeEnum(TimeOfDay),
effort: z.number().min(0.25).max(2),
});
export const makeSessionSchema = (errors: FormErrors) =>
z.object({
personId: z.union([z.literal(newId), z.string().uuid(errors.person)]),
newPerson: z.object({ name: z.string() }),
divisionId: z.union([z.literal(newId), z.string().uuid(errors.division)]),
newDivision: z.object({ name: z.string() }),
taskIds: z.array(z.string()).min(1, errors.task),
newTask: z.object({
name: z.string(),
n: z.number().min(1).max(8),
everyN: z.number().min(1).max(8),
everySpan: z.nativeEnum(Span),
xp: z.number().min(1).max(64),
}),
newExecution: z.object({
date: z.date(),
effort: z.number().min(0.5).max(2),
}),
date: z.date(),
lines: z.array(lineSchema).min(1, errors.task),
});
export type FormSchema = z.infer<ReturnType<typeof makeFormSchema>>;
export type SessionSchema = z.infer<ReturnType<typeof makeSessionSchema>>;
// Server payload: the client resolves each line's time-of-day bucket to a full timestamp in its own
// timezone (see applyTimeOfDay), so executions land at the user's wall-clock hour, not the server's.
export const makeRegisterSchema = (errors: FormErrors) =>
z.object({
personId: z.union([z.literal(newId), z.string().uuid(errors.person)]),
newPerson: z.object({ name: z.string() }),
executions: z
.array(z.object({ taskId: z.string().uuid(), date: z.date(), effort: z.number().min(0.25).max(2) }))
.min(1, errors.task),
});
export const makeCreateTaskSchema = (errors: { division: string; task: string }) =>
z.object({
divisionId: z.union([z.literal(newId), z.string().uuid(errors.division)]),
newDivision: z.object({ name: z.string() }),
name: z.string().min(1, errors.task),
description: z.string(),
n: z.number().min(1).max(32),
everyN: z.number().min(1).max(32),
everySpan: z.nativeEnum(Span),
xp: z.number().min(1).max(64),
});
export type CreateTaskSchema = z.infer<ReturnType<typeof makeCreateTaskSchema>>;
+30
View File
@@ -0,0 +1,30 @@
'use client';
import { Controller, useFormContext } from 'react-hook-form';
import type { Person } from '@/db/types';
import { DateStepper } from './date-stepper';
import { PersonPicker } from './person-picker';
import type { SessionSchema } from './schema';
import type { FormI18n } from './types';
type Props = {
people: Person[];
defaultPersonId?: string;
i18n: FormI18n;
};
export function SessionHeader({ people, defaultPersonId, i18n }: Props) {
const form = useFormContext<SessionSchema>();
return (
<div className='space-y-6'>
<PersonPicker people={people} defaultPersonId={defaultPersonId} i18n={i18n} />
<Controller
name='date'
control={form.control}
render={({ field }) => <DateStepper value={field.value} onChange={field.onChange} i18n={i18n} />}
/>
</div>
);
}
+75
View File
@@ -0,0 +1,75 @@
'use client';
import { Button, FieldError } from '@heroui/react';
import { Plus } from 'lucide-react';
import { useMemo } from 'react';
import { useFieldArray, useFormContext } from 'react-hook-form';
import { timeOfDayFromHours } from '@/config/time-of-day';
import type { Division } from '@/db/types';
import { AddTaskDrawer } from './add-task-drawer';
import type { SessionSchema } from './schema';
import { TaskLine } from './task-line';
import type { FormI18n, TaskWithDivision } from './types';
type Props = {
tasks: TaskWithDivision[];
divisions: Division[];
i18n: FormI18n;
onTaskCreated: (task: TaskWithDivision) => void;
};
export function TaskBasket({ tasks, divisions, i18n, onTaskCreated }: Props) {
const form = useFormContext<SessionSchema>();
const { fields, append, remove } = useFieldArray({ control: form.control, name: 'lines' });
const tasksById = useMemo(() => new Map(tasks.map(task => [task.id, task])), [tasks]);
const usedTaskIds = useMemo(() => new Set(fields.map(field => field.taskId)), [fields]);
const linesError = form.formState.errors.lines;
return (
<div className='space-y-3'>
<p className='font-medium text-sm'>{i18n.fields.session.what}</p>
<ul className='space-y-2'>
{fields.map((field, index) => {
const task = tasksById.get(field.taskId);
return task ? (
<TaskLine
key={field.id}
index={index}
task={task}
control={form.control}
onRemove={() => remove(index)}
i18n={i18n}
/>
) : null;
})}
</ul>
{linesError && <FieldError className='text-danger text-sm'>{linesError.message as string}</FieldError>}
<AddTaskDrawer
tasks={tasks}
divisions={divisions}
usedTaskIds={usedTaskIds}
i18n={i18n}
onAdd={taskIds => {
for (const taskId of taskIds) {
append({ taskId, timeOfDay: timeOfDayFromHours(new Date().getHours()), effort: 1 });
}
}}
onRemove={taskIds => {
const indices = fields.flatMap((field, index) => (taskIds.includes(field.taskId) ? [index] : []));
if (indices.length) {
remove(indices);
}
}}
onTaskCreated={onTaskCreated}
>
<Button type='button' variant='outline' fullWidth>
<Plus className='mr-2 h-4 w-4' />
{i18n.actions.addTask}
</Button>
</AddTaskDrawer>
</div>
);
}
-202
View File
@@ -1,202 +0,0 @@
'use client';
import { Description, FieldError, Input, Label, ListBox, Select, Separator, TextField } from '@heroui/react';
import { ChevronDown, Plus } from 'lucide-react';
import { useMemo } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { InputNumber } from '@/components/ui/input-number';
import type { Task } from '@/db/types';
import type { I18N } from '@/i18n';
import { stringifyFreq } from '@/lib/span';
import { Span } from '@/lib/span-enum';
import { cn } from '@/lib/utils';
import { type FormSchema, newId } from './schema';
type Props = {
i18n: { labels: Pick<I18N['common']['labels'], 'every' | 'span' | 'time' | 'by'> } & Pick<
I18N['add']['form']['fields'],
'task' | 'newTask'
>;
tasks: Task[];
};
export function TaskClient({ i18n, tasks }: Props) {
const form = useFormContext<FormSchema>();
const selectedDivisionId = form.watch('divisionId');
const selectedTaskIds = form.watch('taskIds');
const currentNewTaskN = form.watch('newTask.n');
const currentNewTaskEveryN = form.watch('newTask.everyN');
const options = useMemo(
() => tasks.filter(({ divisionId }) => divisionId === selectedDivisionId),
[selectedDivisionId, tasks],
);
return (
<>
<Controller
name='taskIds'
control={form.control}
render={({ field, fieldState }) => (
<Select
aria-label={i18n.task.label}
selectionMode='multiple'
value={field.value ?? []}
onChange={keys => field.onChange(Array.isArray(keys) ? keys.map(String) : [])}
isInvalid={!!fieldState.error}
className={cn('transition-opacity', !selectedDivisionId && 'pointer-events-none opacity-25')}
>
<Label>{i18n.task.label}</Label>
<Select.Trigger>
<Select.Value />
<Select.Indicator>
<ChevronDown className='h-4 w-4 opacity-50' />
</Select.Indicator>
</Select.Trigger>
<Select.Popover>
<ListBox selectionMode='multiple'>
{options.map(task => (
<ListBox.Item key={task.id} id={task.id} textValue={task.name}>
<div className='grid'>
{task.name}
<small className='mt-1 font-thin text-xs leading-none opacity-50'>
{stringifyFreq(i18n.labels, task)}
</small>
</div>
<ListBox.ItemIndicator />
</ListBox.Item>
))}
<ListBox.Item id={newId} textValue={i18n.task.new}>
<span className='flex items-center gap-1'>
<Plus className='h-4 w-4' />
{i18n.task.new}
</span>
<ListBox.ItemIndicator />
</ListBox.Item>
</ListBox>
</Select.Popover>
{fieldState.error ? (
<FieldError>{fieldState.error.message}</FieldError>
) : (
<Description>{i18n.task[selectedDivisionId ? 'description' : 'notReady']}</Description>
)}
</Select>
)}
/>
{selectedTaskIds?.includes(newId) && (
<>
<Controller
name='newTask.name'
control={form.control}
render={({ field, fieldState }) => (
<TextField
name={field.name}
value={field.value ?? ''}
onChange={field.onChange}
onBlur={field.onBlur}
isInvalid={!!fieldState.error}
>
<Label>{i18n.newTask.name.label}</Label>
<Input ref={field.ref} placeholder={i18n.newTask.name.placeholder} />
{fieldState.error ? (
<FieldError>{fieldState.error.message}</FieldError>
) : (
<Description>{i18n.newTask.name.description}</Description>
)}
</TextField>
)}
/>
<div className='space-y-2'>
<p className='font-medium text-sm'>{i18n.newTask.frequency.label}</p>
<span className='flex items-center gap-2'>
<Controller
name='newTask.n'
control={form.control}
render={({ field }) => (
<InputNumber
aria-label={i18n.labels.time.plural}
className='max-w-[120px]'
value={field.value}
onChange={field.onChange}
min={1}
max={32}
/>
)}
/>
{i18n.labels.time[Number(currentNewTaskN) === 1 ? 'singular' : 'plural']}
</span>
<div className='grid grid-cols-[max-content_max-content_3fr] items-center gap-2'>
<span className='w-max'>{i18n.labels.every}</span>
<Controller
name='newTask.everyN'
control={form.control}
render={({ field }) => (
<InputNumber
aria-label={i18n.labels.every}
className='max-w-[120px]'
value={field.value}
onChange={field.onChange}
min={1}
max={32}
/>
)}
/>
<Controller
name='newTask.everySpan'
control={form.control}
render={({ field }) => (
<Select
aria-label={i18n.newTask.frequency.label}
value={field.value}
onChange={key => field.onChange(String(key))}
>
<Select.Trigger>
<Select.Value />
<Select.Indicator>
<ChevronDown className='h-4 w-4 opacity-50' />
</Select.Indicator>
</Select.Trigger>
<Select.Popover>
<ListBox>
{[Span.HOUR, Span.DAY, Span.WEEK, Span.MONTH, Span.YEAR, Span.DECADE, Span.CENTURY].map(
span => (
<ListBox.Item key={span} id={span} textValue={i18n.labels.span[span].singular}>
{i18n.labels.span[span][Number(currentNewTaskEveryN) === 1 ? 'singular' : 'plural']}
<ListBox.ItemIndicator />
</ListBox.Item>
),
)}
</ListBox>
</Select.Popover>
</Select>
)}
/>
</div>
</div>
<Controller
name='newTask.xp'
control={form.control}
render={({ field }) => (
<div className='space-y-2'>
<p className='font-medium text-sm'>{i18n.newTask.xp.label}</p>
<InputNumber
aria-label={i18n.newTask.xp.label}
className='max-w-[120px]'
value={field.value}
onChange={field.onChange}
min={1}
max={32}
/>
<p className='text-muted-foreground text-xs'>{i18n.newTask.xp.description}</p>
</div>
)}
/>
<Separator />
</>
)}
</>
);
}
+92
View File
@@ -0,0 +1,92 @@
'use client';
import { Button, Popover, Slider } from '@heroui/react';
import { X } from 'lucide-react';
import { type Control, Controller } from 'react-hook-form';
import { DivisionBadge } from '@/components/division-badge';
import { cn } from '@/lib/utils';
import type { SessionSchema } from './schema';
import { TimeOfDayStepper } from './time-of-day-stepper';
import type { FormI18n, TaskWithDivision } from './types';
type Props = {
index: number;
task: TaskWithDivision;
control: Control<SessionSchema>;
onRemove: () => void;
i18n: FormI18n;
};
function EffortField({ value, onChange, i18n }: { value: number; onChange: (value: number) => void; i18n: FormI18n }) {
return (
<Popover>
<Popover.Trigger>
<Button variant='outline' size='sm' className='w-14 shrink-0 font-normal tabular-nums'>
{`${value.toFixed(2)}x`}
</Button>
</Popover.Trigger>
<Popover.Content className='w-64 p-4'>
<Popover.Dialog className='space-y-2'>
<p className='font-medium text-sm'>{i18n.fields.newExecution.effort.label}</p>
<Slider
aria-label={i18n.fields.newExecution.effort.label}
minValue={0.25}
maxValue={2}
step={0.05}
value={value}
onChange={next => onChange(Array.isArray(next) ? next[0] : next)}
>
<Slider.Track>
<Slider.Fill />
<Slider.Thumb />
</Slider.Track>
</Slider>
<p className='text-muted-foreground text-xs'>{i18n.fields.newExecution.effort.description}</p>
</Popover.Dialog>
</Popover.Content>
</Popover>
);
}
export function TaskLine({ index, task, control, onRemove, i18n }: Props) {
const removeButton = (className: string) => (
<Button
variant='ghost'
isIconOnly
size='sm'
className={cn('h-7 w-7 shrink-0 p-1 text-muted-foreground', className)}
aria-label={i18n.actions.remove}
onPress={onRemove}
>
<X className='h-4 w-4' />
</Button>
);
return (
<li className='flex flex-col gap-2 rounded-md border border-input p-2 sm:flex-row sm:items-center'>
<div className='flex min-w-0 flex-1 items-center gap-2'>
<span className='min-w-0 truncate font-medium text-sm'>{task.name}</span>
<DivisionBadge {...task.division} />
{removeButton('ml-auto sm:hidden')}
</div>
<div className='flex items-center justify-end gap-2'>
<Controller
name={`lines.${index}.timeOfDay`}
control={control}
render={({ field }) => (
<TimeOfDayStepper value={field.value} onChange={field.onChange} labels={i18n.labels.timeOfDay} />
)}
/>
<Controller
name={`lines.${index}.effort`}
control={control}
render={({ field }) => <EffortField value={field.value} onChange={field.onChange} i18n={i18n} />}
/>
{removeButton('hidden sm:inline-flex')}
</div>
</li>
);
}
+78
View File
@@ -0,0 +1,78 @@
'use client';
import { Button } from '@heroui/react';
import { Check } from 'lucide-react';
import type { CSSProperties } from 'react';
import { colorHueFromId, hueNeedsDarkText } from '@/lib/color';
import { cn } from '@/lib/utils';
import chipStyles from './division-chip.module.css';
import type { TaskWithDivision } from './types';
type Props = {
name: string;
rooms: TaskWithDivision[];
selected: Set<string>;
onToggle: (taskId: string) => void;
};
const hueStyle = (hue: number, selected: boolean) =>
({ '--chip-hue': hue, ...(selected && { color: hueNeedsDarkText(hue) ? '#000' : '#fff' }) }) as CSSProperties;
type ChipProps = { name: string; hue: number; selected: boolean; onPress: () => void };
function DivisionChip({ name, hue, selected, onPress }: ChipProps) {
return (
<Button
size='sm'
variant='outline'
onPress={onPress}
style={hueStyle(hue, selected)}
className={cn('gap-1', chipStyles.chip, selected && chipStyles.selected)}
>
{selected && <Check className='h-3.5 w-3.5 shrink-0' />}
{name}
</Button>
);
}
export function TaskOption({ name, rooms, selected, onToggle }: Props) {
if (rooms.length === 1) {
const task = rooms[0];
const isSelected = selected.has(task.id);
const hue = colorHueFromId(task.division.id);
return (
<Button
variant='outline'
fullWidth
style={hueStyle(hue, isSelected)}
className={cn(
'h-auto min-h-10 items-center justify-start gap-2 whitespace-normal py-2 text-left',
chipStyles.chip,
isSelected && chipStyles.selected,
)}
onPress={() => onToggle(task.id)}
>
{isSelected && <Check className='h-4 w-4 shrink-0' />}
<span className='flex-1'>{name}</span>
<span className='shrink-0 text-xs opacity-60'>{task.division.name}</span>
</Button>
);
}
return (
<div className='space-y-2'>
<p className='font-medium text-sm'>{name}</p>
<div className='flex flex-wrap gap-2'>
{rooms.map(task => (
<DivisionChip
key={task.id}
name={task.division.name}
hue={colorHueFromId(task.division.id)}
selected={selected.has(task.id)}
onPress={() => onToggle(task.id)}
/>
))}
</div>
</div>
);
}
-30
View File
@@ -1,30 +0,0 @@
import { asc, eq, isNull } from 'drizzle-orm';
import pick from 'lodash.pick';
import { getRequestDb } from '@/db/request';
import { task, taskPeriod } from '@/db/schema';
import { getDict } from '@/i18n/server';
import { TaskClient } from './task-client';
export async function Task() {
const db = await getRequestDb();
const dict = await getDict();
// Order by the TaskPeriod view's `period` (ascending).
const tasks = (
await db
.select({ task })
.from(task)
.leftJoin(taskPeriod, eq(taskPeriod.id, task.id))
.where(isNull(task.retiredAt))
.orderBy(asc(taskPeriod.period))
).map(row => row.task);
return (
<TaskClient
tasks={tasks}
i18n={{
labels: pick(dict.common.labels, ['every', 'span', 'time', 'by']),
...pick(dict.add.form.fields, ['task', 'newTask']),
}}
/>
);
}
+52
View File
@@ -0,0 +1,52 @@
'use client';
import { Button } from '@heroui/react';
import { ChevronLeft, ChevronRight, Moon, Sun, Sunrise } from 'lucide-react';
import { type TimeOfDay, TimeOfDay as TOD, timesOfDay } from '@/config/time-of-day';
import type { I18N } from '@/i18n';
const icon: Record<TimeOfDay, typeof Sun> = {
[TOD.MORNING]: Sunrise,
[TOD.AFTERNOON]: Sun,
[TOD.NIGHT]: Moon,
};
type Props = {
value: TimeOfDay;
onChange: (value: TimeOfDay) => void;
labels: I18N['common']['labels']['timeOfDay'];
};
// All labels are stacked in one grid cell (only the active one shown) so the control's width
// settles on the widest label and doesn't jump between morning, afternoon, and night.
export function TimeOfDayStepper({ value, onChange, labels }: Props) {
const cycle = (delta: number) =>
onChange(timesOfDay[(timesOfDay.indexOf(value) + delta + timesOfDay.length) % timesOfDay.length]);
return (
<div className='flex items-center'>
<Button variant='ghost' isIconOnly size='sm' className='h-7 w-7 p-1' onPress={() => cycle(-1)}>
<ChevronLeft className='h-4 w-4' />
</Button>
<span className='grid justify-items-center px-1 text-sm'>
{timesOfDay.map(tod => {
const Icon = icon[tod];
return (
<span
key={tod}
aria-hidden={tod !== value}
className='col-start-1 row-start-1 flex items-center gap-1.5'
style={{ visibility: tod === value ? 'visible' : 'hidden' }}
>
<Icon className='h-4 w-4 shrink-0 opacity-70' />
{labels[tod]}
</span>
);
})}
</span>
<Button variant='ghost' isIconOnly size='sm' className='h-7 w-7 p-1' onPress={() => cycle(1)}>
<ChevronRight className='h-4 w-4' />
</Button>
</div>
);
}
+8
View File
@@ -0,0 +1,8 @@
import type { Division, Task } from '@/db/types';
import type { I18N } from '@/i18n';
export type TaskWithDivision = Task & { division: Division };
export type FormI18n = Pick<I18N['add']['form'], 'actions' | 'fields' | 'messages'> & {
labels: Pick<I18N['common']['labels'], 'every' | 'span' | 'time' | 'timeOfDay' | 'today' | 'yesterday' | 'and'>;
};
+26 -32
View File
@@ -1,15 +1,11 @@
import { eq } from 'drizzle-orm';
import { asc, desc, eq, isNull } from 'drizzle-orm';
import pick from 'lodash.pick';
import type { Metadata } from 'next/types';
import { getRequestDb } from '@/db/request';
import { task as taskTable } from '@/db/schema';
import { getRequestContext } from '@/db/request';
import { division as divisionTable, person as personTable, taskPeriod, task as taskTable } from '@/db/schema';
import { getDict } from '@/i18n/server';
import { getTitle } from '@/i18n/title';
import { AddForm } from './form';
import { Division } from './form/division';
import { Execution } from './form/execution';
import { Person } from './form/person';
import { Task } from './form/task';
export async function generateMetadata(): Promise<Metadata> {
const dict = await getDict();
@@ -19,40 +15,38 @@ export async function generateMetadata(): Promise<Metadata> {
type SearchParams = { task?: string; from?: string };
export default async function Add({ searchParams }: { searchParams: Promise<SearchParams> }) {
const [dict, { task, from }] = await Promise.all([getDict(), searchParams]);
const [dict, { task, from }, { db, user }] = await Promise.all([getDict(), searchParams, getRequestContext()]);
let prefill: { divisionId: string; taskId: string } | undefined;
if (task) {
const db = await getRequestDb();
const [row] = await db
.select({ divisionId: taskTable.divisionId })
const [people, divisions, taskRows] = await Promise.all([
db.select().from(personTable).orderBy(asc(personTable.name)),
db.select().from(divisionTable).orderBy(asc(divisionTable.name)),
db
.select({ task: taskTable, division: divisionTable })
.from(taskTable)
.where(eq(taskTable.id, task))
.limit(1);
if (row) {
prefill = { divisionId: row.divisionId, taskId: task };
}
}
.innerJoin(divisionTable, eq(divisionTable.id, taskTable.divisionId))
.leftJoin(taskPeriod, eq(taskPeriod.id, taskTable.id))
.where(isNull(taskTable.retiredAt))
.orderBy(desc(taskPeriod.urgency)),
]);
const tasks = taskRows.map(({ task, division }) => ({ ...task, division }));
const prefillTaskId = task && tasks.some(t => t.id === task) ? task : undefined;
return (
<div className='m-auto grid max-w-screen-sm gap-8'>
<h1 className='scroll-m-20 text-center font-extrabold text-4xl tracking-tight lg:text-5xl'>{dict.add.title}</h1>
<AddForm
sections={[
<Person key={'person'} />,
<Division key={'division'} />,
<Task key={'task'} />,
<Execution key={'execution'} />,
]}
i18n={{ ...pick(dict.add.form, ['actions', 'messages']), and: dict.common.labels.and }}
errors={{
person: dict.add.form.fields.person.error,
division: dict.add.form.fields.division.error,
task: dict.add.form.fields.task.empty,
}}
prefill={prefill}
people={people}
divisions={divisions}
tasks={tasks}
defaultPersonId={user?.personId}
prefillTaskId={prefillTaskId}
returnTo={from}
i18n={{
...pick(dict.add.form, ['actions', 'fields', 'messages']),
labels: pick(dict.common.labels, ['every', 'span', 'time', 'timeOfDay', 'today', 'yesterday', 'and']),
}}
/>
</div>
);
+1 -1
View File
@@ -64,7 +64,7 @@ export default async function NextTasks() {
{task.name}
{!!task.description?.length && (
<StopClick>
<HybridTooltip content={<p>{task.description}</p>}>
<HybridTooltip content={<p className='break-normal'>{task.description}</p>}>
<Button variant='ghost' isIconOnly className='h-6 w-6 rounded-full p-1 text-muted-foreground'>
<Info className='h-4 w-4' />
</Button>
+1 -1
View File
@@ -140,7 +140,7 @@ export const StatsTable = ({
</StopClick>
{!!task.description?.length && (
<StopClick>
<HybridTooltip content={<p>{task.description}</p>}>
<HybridTooltip content={<p className='break-normal'>{task.description}</p>}>
<Button variant='ghost' isIconOnly className='h-6 w-6 rounded-full p-1 text-muted-foreground'>
<Info className='h-4 w-4' />
</Button>
+1 -2
View File
@@ -1,6 +1,5 @@
import type { Division } from '@/db/types';
import { colorHueFromId } from '@/lib/color';
import { ColorBadge } from '../ui/color-badge';
const colorHueFromId = (id: string) => (360 * Number(`0x${id.split('-')[0]}`)) / Number('0xffffffff');
export const DivisionBadge = ({ id, name }: Division) => <ColorBadge colorHue={colorHueFromId(id)}>{name}</ColorBadge>;
+2 -6
View File
@@ -1,5 +1,6 @@
import omit from 'lodash.omit';
import { getMetal, type Metal } from '@/components/medal';
import { hueNeedsDarkText } from '@/lib/color';
import { cn } from '@/lib/utils';
import styles from './index.module.css';
@@ -21,18 +22,13 @@ export type BadgeProps = React.HTMLAttributes<HTMLDivElement> &
))
);
const peakHue = 114;
const threshold = 80;
function ColorBadge({ className, variant = 'filled', style, ...props }: BadgeProps) {
return (
<div
className={cn(
'inline-flex items-center rounded-full border px-2.5 py-0.5 font-semibold text-xs transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
styles[variant],
variant === 'filled' &&
'colorHue' in props &&
(Math.abs(props.colorHue - peakHue) < threshold ? 'text-black' : 'text-white'),
variant === 'filled' && 'colorHue' in props && (hueNeedsDarkText(props.colorHue) ? 'text-black' : 'text-white'),
'metal' in props && props.metal,
'metalIndex' in props && getMetal(props.metalIndex),
className,
-2
View File
@@ -40,5 +40,3 @@ export function InputNumber({ value, onChange, min, max, className, ...props }:
</NumberField>
);
}
export { InputNumber as default };
+27
View File
@@ -0,0 +1,27 @@
// Coarse time-of-day buckets used when logging an execution: the session carries a single
// date, each task line picks a bucket, and the bucket maps to a fixed hour on that date.
// Labels are i18n (common.labels.timeOfDay); the hour mapping lives here, not in the DB.
export const timesOfDay = ['MORNING', 'AFTERNOON', 'NIGHT'] as const;
export type TimeOfDay = (typeof timesOfDay)[number];
export const TimeOfDay = {
MORNING: 'MORNING',
AFTERNOON: 'AFTERNOON',
NIGHT: 'NIGHT',
} as const satisfies Record<TimeOfDay, TimeOfDay>;
export const hourByTimeOfDay: Record<TimeOfDay, number> = {
[TimeOfDay.MORNING]: 9,
[TimeOfDay.AFTERNOON]: 15,
[TimeOfDay.NIGHT]: 21,
};
export const timeOfDayFromHours = (hours: number): TimeOfDay =>
hours < 12 ? TimeOfDay.MORNING : hours < 18 ? TimeOfDay.AFTERNOON : TimeOfDay.NIGHT;
export const applyTimeOfDay = (date: Date, timeOfDay: TimeOfDay) => {
const next = new Date(date);
next.setHours(hourByTimeOfDay[timeOfDay], 0, 0, 0);
return next;
};
+43 -27
View File
@@ -37,38 +37,46 @@
"add": {
"form": {
"actions": {
"register": "Register"
"register": "Register",
"registerCount": {
"singular": "Register {{count}} task",
"plural": "Register {{count}} tasks"
},
"addTask": "Add task",
"addCount": {
"singular": "Add {{count}}",
"plural": "Add {{count}}"
},
"removeCount": "Remove {{count}}",
"addRemoveCount": "Add {{add}}, remove {{remove}}",
"create": "Create a new task",
"cancel": "Cancel",
"remove": "Remove"
},
"fields": {
"session": {
"who": "Who",
"date": "When",
"what": "What",
"previousDay": "Previous day",
"nextDay": "Next day"
},
"division": {
"description": "Where did the task take place?",
"error": "No division selected",
"label": "Division",
"new": "New division",
"placeholder": ""
"placeholder": "Pick a division"
},
"newDivision": {
"name": {
"description": "Name for the new division",
"label": "Name",
"placeholder": "Living room"
}
},
"newExecution": {
"date": {
"description": "In which date did the task take place?",
"label": "Date",
"placeholder": "Pick a date"
},
"effort": {
"description": "A task does not always require the same level of effort. This field affects how much XP is rewarded.",
"label": "Effort"
},
"time": {
"description": "And at what time?",
"label": "Time",
"hours": "Hours",
"minutes": "Minutes"
}
},
"newPerson": {
@@ -82,9 +90,13 @@
"label": "Frequency"
},
"name": {
"description": "Name for the new task",
"label": "Name",
"placeholder": "Vacuum"
"placeholder": "Vacuum",
"error": "Give the task a name"
},
"description": {
"label": "Description",
"placeholder": "Optional notes about the task"
},
"xp": {
"description": "Reward for executing the task",
@@ -92,18 +104,14 @@
}
},
"person": {
"description": "Who executed the task?",
"error": "No one selected",
"label": "Person",
"new": "New person",
"placeholder": ""
"new": "New person"
},
"task": {
"description": "Choose one of the tasks or define a new one",
"empty": "You must select at least one task",
"empty": "Add at least one task",
"label": "Task",
"new": "New task",
"notReady": "Choose a division before selecting a task"
"search": "Search tasks…",
"notFound": "No tasks match"
}
},
"messages": {
@@ -113,10 +121,11 @@
"singular": "{{person}} got {{xp}} XP for the task {{task}}!"
},
"title": "Success"
}
},
"error": "Something went wrong"
}
},
"title": "Register task execution"
"title": "Register a session"
},
"common": {
"tagline": "Did something useful",
@@ -125,6 +134,13 @@
"by": "each",
"every": "every",
"of": "of",
"today": "Today",
"yesterday": "Yesterday",
"timeOfDay": {
"MORNING": "Morning",
"AFTERNOON": "Afternoon",
"NIGHT": "Night"
},
"span": {
"CENTURY": {
"plural": "centuries",
+44 -28
View File
@@ -37,38 +37,46 @@
"add": {
"form": {
"actions": {
"register": "Registar"
"register": "Registar",
"registerCount": {
"singular": "Registar {{count}} tarefa",
"plural": "Registar {{count}} tarefas"
},
"addTask": "Adicionar tarefa",
"addCount": {
"singular": "Adicionar {{count}}",
"plural": "Adicionar {{count}}"
},
"removeCount": "Remover {{count}}",
"addRemoveCount": "Adicionar {{add}}, remover {{remove}}",
"create": "Criar uma nova tarefa",
"cancel": "Cancelar",
"remove": "Remover"
},
"fields": {
"session": {
"who": "Quem",
"date": "Quando",
"what": "O quê",
"previousDay": "Dia anterior",
"nextDay": "Dia seguinte"
},
"division": {
"description": "Onde foi feita a tarefa?",
"error": "Nenhuma divisão selecionada",
"label": "Divisão",
"new": "Nova divisão",
"placeholder": ""
"placeholder": "Escolhe uma divisão"
},
"newDivision": {
"name": {
"description": "Nome da nova divisão",
"label": "Nome",
"placeholder": "Sala"
}
},
"newExecution": {
"date": {
"description": "Em que dia foi feita a tarefa?",
"label": "Data",
"placeholder": "Escolhe uma data"
},
"effort": {
"description": "Uma tarefa nem sempre requer o mesmo esforço. Este campo afeta o XP desta execução da tarefa.",
"label": "Esforço"
},
"time": {
"description": "E a que horas?",
"label": "Hora",
"hours": "Horas",
"minutes": "Minutos"
}
},
"newPerson": {
@@ -82,28 +90,28 @@
"label": "Frequência"
},
"name": {
"description": "Nome da nova tarefa",
"label": "Nome",
"placeholder": "Aspirar"
"placeholder": "Aspirar",
"error": "Dá um nome à tarefa"
},
"description": {
"label": "Descrição",
"placeholder": "Notas opcionais sobre a tarefa"
},
"xp": {
"description": "Recompensa por exucação da tarefa",
"description": "Recompensa por execução da tarefa",
"label": "XP"
}
},
"person": {
"description": "Quem fez a tarefa?",
"error": "Nenhuma pessoa selecionada",
"label": "Pessoa",
"new": "Nova pessoa",
"placeholder": ""
"new": "Nova pessoa"
},
"task": {
"description": "Escolhe uma das tarefas ou define uma nova",
"empty": "Tens que escolher pelo menos uma tarefa",
"empty": "Adiciona pelo menos uma tarefa",
"label": "Tarefa",
"new": "Nova tarefa",
"notReady": "Escolhe uma divisão antes de escolher a tarefa"
"search": "Procurar tarefas…",
"notFound": "Nenhuma tarefa corresponde"
}
},
"messages": {
@@ -113,10 +121,11 @@
"singular": "{{person}} ganhou {{xp}} XP pela tarefa {{task}}!"
},
"title": "Sucesso"
}
},
"error": "Algo correu mal"
}
},
"title": "Registar execução de tarefa"
"title": "Registar uma sessão"
},
"common": {
"tagline": "Fiz algo de útil",
@@ -125,6 +134,13 @@
"by": "por",
"every": "a cada",
"of": "de",
"today": "Hoje",
"yesterday": "Ontem",
"timeOfDay": {
"MORNING": "Manhã",
"AFTERNOON": "Tarde",
"NIGHT": "Noite"
},
"span": {
"CENTURY": {
"plural": "séculos",
+31
View File
@@ -0,0 +1,31 @@
export const colorHueFromId = (id: string) => (360 * Number(`0x${id.split('-')[0]}`)) / Number('0xffffffff');
// The filled swatch used across badges/chips is hsl(hue 100% 40%). Pick readable text by that
// swatch's WCAG relative luminance: dark text once the background crosses the ~0.179 midpoint
// between black and white contrast (so bright yellows/greens get black, dark reds/blues get white).
const filledSaturation = 1;
const filledLightness = 0.4;
const toLinear = (channel: number) => (channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4);
export const hueNeedsDarkText = (hue: number) => {
const chroma = (1 - Math.abs(2 * filledLightness - 1)) * filledSaturation;
const sextant = (((hue % 360) + 360) % 360) / 60;
const second = chroma * (1 - Math.abs((sextant % 2) - 1));
const base = filledLightness - chroma / 2;
const [r, g, b] = (
sextant < 1
? [chroma, second, 0]
: sextant < 2
? [second, chroma, 0]
: sextant < 3
? [0, chroma, second]
: sextant < 4
? [0, second, chroma]
: sextant < 5
? [second, 0, chroma]
: [chroma, 0, second]
).map(channel => channel + base);
const luminance = 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
return luminance > 0.179;
};