Better task registration UX!
This commit is contained in:
+54
-50
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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%);
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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'])} />;
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>>;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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']),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -40,5 +40,3 @@ export function InputNumber({ value, onChange, min, max, className, ...props }:
|
||||
</NumberField>
|
||||
);
|
||||
}
|
||||
|
||||
export { InputNumber as default };
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user