Compare commits
2 Commits
41504e8c0a
...
3c9d7e4e9d
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c9d7e4e9d | |||
| 817d5eb7f1 |
@@ -0,0 +1,28 @@
|
||||
name: Demo random tasks
|
||||
|
||||
# Keeps the guest demo sandbox lively by logging random task activity on a schedule.
|
||||
on:
|
||||
schedule:
|
||||
- cron: '53 * * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: demo-random-tasks-fizalgo
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
seed-activity:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: docker:latest
|
||||
steps:
|
||||
- name: Add random activity to the demo database
|
||||
run: |
|
||||
cid=$(docker ps -q \
|
||||
-f label=com.docker.compose.project=fizalgo \
|
||||
-f label=com.docker.compose.service=app)
|
||||
if [ -z "$cid" ]; then
|
||||
echo "fizalgo app container is not running; skipping" >&2
|
||||
exit 1
|
||||
fi
|
||||
docker exec "$cid" bun scripts/demo-random-tasks.ts
|
||||
@@ -11,6 +11,7 @@
|
||||
"db:migrate": "bun scripts/migrate.ts",
|
||||
"db:seed": "bun scripts/seed.ts",
|
||||
"db:reset-demo": "bun scripts/reset-demo.ts",
|
||||
"db:demo-activity": "bun scripts/demo-random-tasks.ts",
|
||||
"db:create-user": "bun scripts/create-user.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -18,6 +19,7 @@
|
||||
"@heroui/react": "3",
|
||||
"@hookform/resolvers": "5",
|
||||
"@internationalized/date": "3",
|
||||
"canvas-confetti": "^1.9.0",
|
||||
"clsx": "^2.1.0",
|
||||
"date-fns": "4",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
@@ -40,6 +42,7 @@
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2",
|
||||
"@tailwindcss/postcss": "4",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/lodash.get": "^4.4.9",
|
||||
"@types/lodash.omit": "^4.5.9",
|
||||
"@types/lodash.pick": "^4.4.9",
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import postgres from 'postgres';
|
||||
import { demoUrl } from '../src/db/connection';
|
||||
import * as schema from '../src/db/schema';
|
||||
import { insertRandomExecutions } from '../src/lib/random-executions';
|
||||
|
||||
const sql = postgres(demoUrl, { max: 1 });
|
||||
const db = drizzle(sql, { schema });
|
||||
|
||||
const count = await insertRandomExecutions(db);
|
||||
|
||||
await sql.end();
|
||||
console.log(`demo: inserted ${count} random executions`);
|
||||
process.exit(0);
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
createSession,
|
||||
deleteSessionCookie,
|
||||
generateSessionToken,
|
||||
getCurrentUser,
|
||||
invalidateSession,
|
||||
readSessionCookie,
|
||||
setSessionCookie,
|
||||
@@ -53,5 +52,3 @@ export const logout = async () => {
|
||||
}
|
||||
redirect('/');
|
||||
};
|
||||
|
||||
export { getCurrentUser };
|
||||
|
||||
@@ -9,6 +9,7 @@ import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { applyTimeOfDay, timeOfDayFromHours } from '@/config/time-of-day';
|
||||
import type { Division, Person } from '@/db/types';
|
||||
import { tReplacer } from '@/i18n/t';
|
||||
import { celebrate } from '@/lib/confetti';
|
||||
import { registerExecution } from '../actions';
|
||||
import { makeSessionSchema, newId, type SessionSchema } from './schema';
|
||||
import { SessionHeader } from './session-header';
|
||||
@@ -90,15 +91,18 @@ export function AddForm({ people, divisions, tasks, defaultPersonId, prefillTask
|
||||
|
||||
const taskNames = executions.map(({ task }) => `"${task.name}"`);
|
||||
const lastTaskName = taskNames.pop();
|
||||
const xp = Math.round(executions.reduce((total, { task, effort }) => total + task.xp * effort, 0));
|
||||
|
||||
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.labels.and} `),
|
||||
xp: String(Math.round(executions.reduce((total, { task, effort }) => total + task.xp * effort, 0))),
|
||||
xp: String(xp),
|
||||
}),
|
||||
});
|
||||
|
||||
celebrate(xp);
|
||||
|
||||
if (returnTo) {
|
||||
router.push(returnTo);
|
||||
return;
|
||||
|
||||
@@ -2,40 +2,15 @@
|
||||
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
import { getRequestDb } from '@/db/request';
|
||||
import { execution, person, task } from '@/db/schema';
|
||||
import { getCurrentUser } from '@/lib/auth';
|
||||
import { insertRandomExecutions } from '@/lib/random-executions';
|
||||
|
||||
export async function doThings() {
|
||||
if (await getCurrentUser()) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const db = await getRequestDb();
|
||||
const personMaxTimes = 6;
|
||||
const taskMaxTimes = 4;
|
||||
|
||||
const people = await db.select().from(person);
|
||||
const tasks = await db.select().from(task);
|
||||
|
||||
const rows: (typeof execution.$inferInsert)[] = [];
|
||||
for (let i = 0; i <= Math.random() * personMaxTimes; i++) {
|
||||
const randomPerson = people[Math.floor(Math.random() * people.length)];
|
||||
|
||||
for (let j = 0; j <= Math.random() * taskMaxTimes; j++) {
|
||||
const randomTask = tasks[Math.floor(Math.random() * tasks.length)];
|
||||
|
||||
rows.push({
|
||||
personId: randomPerson.id,
|
||||
taskId: randomTask.id,
|
||||
date: new Date(Date.now() - Math.random() * 24 * 60 * 60 * 1000),
|
||||
effort: Math.random() * 1.5 + 0.5,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (rows.length) {
|
||||
await db.insert(execution).values(rows);
|
||||
}
|
||||
await insertRandomExecutions(await getRequestDb());
|
||||
|
||||
return redirect('/stats');
|
||||
}
|
||||
|
||||
+87
-1
@@ -20,7 +20,7 @@
|
||||
--sc-border: 240 3.7% 15.9%;
|
||||
--sc-input: 240 3.7% 15.9%;
|
||||
--sc-ring: 142.4 71.8% 29.2%;
|
||||
--sc-radius: 1rem;
|
||||
--sc-radius: 1.2rem;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@@ -113,3 +113,89 @@ body {
|
||||
background-color: hsl(var(--sc-background));
|
||||
color: hsl(var(--sc-foreground));
|
||||
}
|
||||
|
||||
.button {
|
||||
--btn-lift: 4px;
|
||||
--btn-ledge: color-mix(in srgb, var(--button-bg, transparent) 62%, #000);
|
||||
transform: translateY(0);
|
||||
transition: transform 0.13s var(--ease-out, ease), box-shadow 0.13s var(--ease-out, ease),
|
||||
background-color 0.12s var(--ease-out, ease);
|
||||
box-shadow:
|
||||
0 var(--btn-lift) 0 0 var(--btn-ledge),
|
||||
0 calc(var(--btn-lift) + 3px) 10px -3px rgb(0 0 0 / 0.5),
|
||||
inset 0 1px 0 0 rgb(255 255 255 / 0.22);
|
||||
}
|
||||
|
||||
.button--ghost,
|
||||
.button--outline {
|
||||
--btn-ledge: transparent;
|
||||
}
|
||||
|
||||
.button:hover,
|
||||
.button[data-hovered="true"] {
|
||||
transform: translateY(2px);
|
||||
box-shadow:
|
||||
0 2px 0 0 var(--btn-ledge),
|
||||
0 5px 8px -3px rgb(0 0 0 / 0.45),
|
||||
inset 0 1px 0 0 rgb(255 255 255 / 0.22);
|
||||
}
|
||||
|
||||
.button:active,
|
||||
.button[data-pressed="true"],
|
||||
.button--sm:active,
|
||||
.button--sm[data-pressed="true"],
|
||||
.button--lg:active,
|
||||
.button--lg[data-pressed="true"] {
|
||||
transform: translateY(var(--btn-lift));
|
||||
box-shadow:
|
||||
0 0 0 0 var(--btn-ledge),
|
||||
0 1px 2px 0 rgb(0 0 0 / 0.4),
|
||||
inset 0 2px 5px -1px rgb(0 0 0 / 0.4),
|
||||
inset 0 1px 0 0 rgb(255 255 255 / 0.1);
|
||||
}
|
||||
|
||||
.button:disabled,
|
||||
.button[aria-disabled="true"] {
|
||||
transform: none;
|
||||
box-shadow: inset 0 1px 0 0 rgb(255 255 255 / 0.12);
|
||||
}
|
||||
|
||||
.button--primary::before,
|
||||
.button--primary::after,
|
||||
.button--danger::before,
|
||||
.button--danger::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.button--primary::before,
|
||||
.button--danger::before {
|
||||
background: linear-gradient(
|
||||
118deg,
|
||||
transparent calc(15% - 0.75px),
|
||||
rgb(255 255 255 / 0.26) calc(15% + 0.75px),
|
||||
rgb(255 255 255 / 0.26) calc(23% - 0.75px),
|
||||
transparent calc(23% + 0.75px)
|
||||
);
|
||||
}
|
||||
|
||||
.button--primary::after,
|
||||
.button--danger::after {
|
||||
background: linear-gradient(
|
||||
118deg,
|
||||
transparent calc(30% - 0.75px),
|
||||
rgb(255 255 255 / 0.16) calc(30% + 0.75px),
|
||||
rgb(255 255 255 / 0.16) calc(34% - 0.75px),
|
||||
transparent calc(34% + 0.75px)
|
||||
);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.button {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
+11
-3
@@ -7,11 +7,12 @@ import { PrefillRow, StopClick } from '@/components/prefill-row';
|
||||
import { HybridTooltip } from '@/components/ui/hybrid-tooltip';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { UrgencyBadge } from '@/components/urgency-badge';
|
||||
import { timeOfDayFromHours } from '@/config/time-of-day';
|
||||
import { getRequestDb } from '@/db/request';
|
||||
import { division, execution, task, taskPeriod } from '@/db/schema';
|
||||
import { getDict, getLocale } from '@/i18n/server';
|
||||
import { getTitle } from '@/i18n/title';
|
||||
import { dateFnsLocale, format, formatDistanceToNow } from '@/lib/date-fns';
|
||||
import { dateFnsLocale, format, formatLastExecuted } from '@/lib/date-fns';
|
||||
import { stringifyFreq } from '@/lib/span';
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
@@ -79,9 +80,16 @@ export default async function NextTasks() {
|
||||
<TableCell>
|
||||
{latestExecution?.date && (
|
||||
<div className='flex items-center gap-2'>
|
||||
{formatDistanceToNow(latestExecution.date, { includeSeconds: true, addSuffix: true, locale: df })}
|
||||
{formatLastExecuted(latestExecution.date, dict.common.labels.lastExecuted, df)}
|
||||
<StopClick>
|
||||
<HybridTooltip content={<p>{format(latestExecution.date, 'yyyy/MM/dd HH:mm', { locale: df })}</p>}>
|
||||
<HybridTooltip
|
||||
content={
|
||||
<p>
|
||||
{format(latestExecution.date, 'yyyy/MM/dd', { locale: df })}{' '}
|
||||
{dict.common.labels.timeOfDay[timeOfDayFromHours(latestExecution.date.getHours())]}
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<Button variant='ghost' isIconOnly className='h-6 w-6 rounded-full p-1 text-muted-foreground'>
|
||||
<Info className='h-4 w-4' />
|
||||
</Button>
|
||||
|
||||
@@ -25,9 +25,6 @@ export default async function Home() {
|
||||
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'>fizalgo</h1>
|
||||
<h2 className='scroll-m-20 text-center font-semibold text-3xl text-muted-foreground tracking-tight'>
|
||||
{dict.add.title}
|
||||
</h2>
|
||||
|
||||
<div className='flex flex-col items-center justify-center gap-4 sm:flex-row'>
|
||||
<Link href='/next' className={buttonVariants({ size: 'lg' })}>
|
||||
|
||||
@@ -109,7 +109,7 @@ export default async function StatsContent(props?: ContentIds) {
|
||||
header={<StatsTableHeader {...props} />}
|
||||
i18n={dict.stats.modals.delete}
|
||||
loadMoreLabel={dict.stats.loadMore}
|
||||
labels={pick(dict.common.labels, ['time', 'by', 'every', 'span'])}
|
||||
labels={pick(dict.common.labels, ['time', 'by', 'every', 'span', 'lastExecuted', 'timeOfDay'])}
|
||||
initialData={hasMoreRows ? tableRows.slice(0, -1) : tableRows}
|
||||
initialHasMore={hasMoreRows}
|
||||
peopleById={people.reduce(
|
||||
|
||||
@@ -11,8 +11,9 @@ import { PrefillRow, StopClick } from '@/components/prefill-row';
|
||||
import { ColorBadge } from '@/components/ui/color-badge';
|
||||
import { HybridTooltip } from '@/components/ui/hybrid-tooltip';
|
||||
import { Table, TableBody, TableCell, TableFooter, TableRow } from '@/components/ui/table';
|
||||
import { timeOfDayFromHours } from '@/config/time-of-day';
|
||||
import type { I18N } from '@/i18n';
|
||||
import { dateFnsLocale, format, formatDistanceToNow } from '@/lib/date-fns';
|
||||
import { dateFnsLocale, format, formatLastExecuted } from '@/lib/date-fns';
|
||||
import { stringifyFreq } from '@/lib/span';
|
||||
import { deleteExecution, getTableRows, type TableRows } from './actions';
|
||||
import type { ContentIds } from './content';
|
||||
@@ -21,7 +22,7 @@ type Props = ContentIds & {
|
||||
header: ReactNode;
|
||||
i18n: I18N['stats']['modals']['delete'];
|
||||
loadMoreLabel: string;
|
||||
labels: Pick<I18N['common']['labels'], 'time' | 'by' | 'every' | 'span'>;
|
||||
labels: Pick<I18N['common']['labels'], 'time' | 'by' | 'every' | 'span' | 'lastExecuted' | 'timeOfDay'>;
|
||||
initialData: TableRows;
|
||||
initialHasMore?: boolean;
|
||||
peopleById: Record<string, { name: string; index: number }>;
|
||||
@@ -89,7 +90,10 @@ export const StatsTable = ({
|
||||
<p className='mt-4 text-muted-foreground text-xs'>{i18n.labels.who}</p>
|
||||
<p className='leading-7'>{peopleById[executions[deleteIndex].personId].name}</p>
|
||||
<p className='mt-4 text-muted-foreground text-xs'>{i18n.labels.when}</p>
|
||||
<p className='leading-7'>{format(executions[deleteIndex].date, 'yyyy/MM/dd HH:mm')}</p>
|
||||
<p className='leading-7'>
|
||||
{format(executions[deleteIndex].date, 'yyyy/MM/dd')}{' '}
|
||||
{labels.timeOfDay[timeOfDayFromHours(executions[deleteIndex].date.getHours())]}
|
||||
</p>
|
||||
</div>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
@@ -161,9 +165,16 @@ export const StatsTable = ({
|
||||
)}
|
||||
<TableCell>
|
||||
<div className='flex items-center gap-2'>
|
||||
{formatDistanceToNow(date, { includeSeconds: true, addSuffix: true, locale: df })}
|
||||
{formatLastExecuted(date, labels.lastExecuted, df)}
|
||||
<StopClick>
|
||||
<HybridTooltip content={<p>{format(date, 'yyyy/MM/dd HH:mm', { locale: df })}</p>}>
|
||||
<HybridTooltip
|
||||
content={
|
||||
<p>
|
||||
{format(date, 'yyyy/MM/dd', { locale: df })}{' '}
|
||||
{labels.timeOfDay[timeOfDayFromHours(date.getHours())]}
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<Button variant='ghost' isIconOnly className='h-6 w-6 rounded-full p-1 text-muted-foreground'>
|
||||
<Info className='h-4 w-4' />
|
||||
</Button>
|
||||
|
||||
@@ -22,7 +22,7 @@ export type BadgeProps = React.HTMLAttributes<HTMLDivElement> &
|
||||
))
|
||||
);
|
||||
|
||||
function ColorBadge({ className, variant = 'filled', style, ...props }: BadgeProps) {
|
||||
export function ColorBadge({ className, variant = 'filled', style, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -41,5 +41,3 @@ function ColorBadge({ className, variant = 'filled', style, ...props }: BadgePro
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { ColorBadge };
|
||||
|
||||
+13
-12
@@ -2,7 +2,10 @@ import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import styles from './table.module.css';
|
||||
|
||||
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement> & { shadowColor?: string }>(
|
||||
export const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement> & { shadowColor?: string }
|
||||
>(
|
||||
({ className, shadowColor = 'var(--sc-background)', ...props }, ref) => (
|
||||
<div
|
||||
className={styles.scroller}
|
||||
@@ -21,19 +24,19 @@ const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableE
|
||||
);
|
||||
Table.displayName = 'Table';
|
||||
|
||||
const TableHeader = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||
export const TableHeader = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||
({ className, ...props }, ref) => <thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />,
|
||||
);
|
||||
TableHeader.displayName = 'TableHeader';
|
||||
|
||||
const TableBody = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||
export const TableBody = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} />
|
||||
),
|
||||
);
|
||||
TableBody.displayName = 'TableBody';
|
||||
|
||||
const TableFooter = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||
export const TableFooter = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
@@ -44,7 +47,7 @@ const TableFooter = React.forwardRef<HTMLTableSectionElement, React.HTMLAttribut
|
||||
);
|
||||
TableFooter.displayName = 'TableFooter';
|
||||
|
||||
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
|
||||
export const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
@@ -58,12 +61,12 @@ const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTML
|
||||
);
|
||||
TableRow.displayName = 'TableRow';
|
||||
|
||||
const TableHead = React.forwardRef<HTMLTableCellElement, React.ThHTMLAttributes<HTMLTableCellElement>>(
|
||||
export const TableHead = React.forwardRef<HTMLTableCellElement, React.ThHTMLAttributes<HTMLTableCellElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'h-12 px-4 text-left align-middle font-medium text-muted-foreground md:px-8 [&:has([role=checkbox])]:pr-0',
|
||||
'h-12 px-4 text-left align-middle font-medium text-muted-foreground md:px-8 has-[[role=checkbox]]:pr-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -72,18 +75,16 @@ const TableHead = React.forwardRef<HTMLTableCellElement, React.ThHTMLAttributes<
|
||||
);
|
||||
TableHead.displayName = 'TableHead';
|
||||
|
||||
const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(
|
||||
export const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<td ref={ref} className={cn('p-4 align-middle md:px-8 [&:has([role=checkbox])]:pr-0', className)} {...props} />
|
||||
<td ref={ref} className={cn('p-4 align-middle md:px-8 has-[[role=checkbox]]:pr-0', className)} {...props} />
|
||||
),
|
||||
);
|
||||
TableCell.displayName = 'TableCell';
|
||||
|
||||
const TableCaption = React.forwardRef<HTMLTableCaptionElement, React.HTMLAttributes<HTMLTableCaptionElement>>(
|
||||
export const TableCaption = React.forwardRef<HTMLTableCaptionElement, React.HTMLAttributes<HTMLTableCaptionElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<caption ref={ref} className={cn('mt-4 text-muted-foreground text-sm', className)} {...props} />
|
||||
),
|
||||
);
|
||||
TableCaption.displayName = 'TableCaption';
|
||||
|
||||
export { Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow };
|
||||
|
||||
@@ -3,11 +3,12 @@ import { ColorBadge } from '../ui/color-badge';
|
||||
const colorHues = [90, 45, 0];
|
||||
|
||||
export const UrgencyBadge = ({ urgency }: { urgency: number }) => {
|
||||
const index = Math.max(0, Math.min(2, Math.round(urgency)));
|
||||
const value = Math.max(0, urgency);
|
||||
const index = Math.min(2, Math.round(value));
|
||||
|
||||
return (
|
||||
<ColorBadge colorHue={colorHues[index]} variant='light'>
|
||||
{`${Math.round(urgency * 100)}%`}
|
||||
{`${Math.round(value * 100)}%`}
|
||||
</ColorBadge>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -137,6 +137,18 @@
|
||||
"of": "of",
|
||||
"today": "Today",
|
||||
"yesterday": "Yesterday",
|
||||
"lastExecuted": {
|
||||
"today": {
|
||||
"MORNING": "this morning",
|
||||
"AFTERNOON": "this afternoon",
|
||||
"NIGHT": "tonight"
|
||||
},
|
||||
"yesterday": {
|
||||
"MORNING": "yesterday morning",
|
||||
"AFTERNOON": "yesterday afternoon",
|
||||
"NIGHT": "last night"
|
||||
}
|
||||
},
|
||||
"timeOfDay": {
|
||||
"MORNING": "Morning",
|
||||
"AFTERNOON": "Afternoon",
|
||||
|
||||
@@ -137,6 +137,18 @@
|
||||
"of": "de",
|
||||
"today": "Hoje",
|
||||
"yesterday": "Ontem",
|
||||
"lastExecuted": {
|
||||
"today": {
|
||||
"MORNING": "esta manhã",
|
||||
"AFTERNOON": "esta tarde",
|
||||
"NIGHT": "esta noite"
|
||||
},
|
||||
"yesterday": {
|
||||
"MORNING": "ontem de manhã",
|
||||
"AFTERNOON": "ontem à tarde",
|
||||
"NIGHT": "ontem à noite"
|
||||
}
|
||||
},
|
||||
"timeOfDay": {
|
||||
"MORNING": "Manhã",
|
||||
"AFTERNOON": "Tarde",
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import confetti from 'canvas-confetti';
|
||||
|
||||
// One particle per XP earned, capped so a huge session can't flood the canvas.
|
||||
const MAX_PARTICLES = 200;
|
||||
|
||||
// Primary accent (--sc-primary), the three medal metals (gold/silver/bronze), and white.
|
||||
const COLORS = ['#22c55e', '#ffc800', '#aac1d4', '#d7975d', '#ffffff'];
|
||||
|
||||
// Two cannons in the bottom corners, angled up toward the top center, splitting the particle budget.
|
||||
export function celebrate(xp: number) {
|
||||
const particleCount = Math.min(Math.max(Math.round(xp), 1), MAX_PARTICLES);
|
||||
const perCannon = Math.ceil(particleCount / 2);
|
||||
|
||||
const shared: confetti.Options = {
|
||||
particleCount: perCannon,
|
||||
spread: 60,
|
||||
startVelocity: 45,
|
||||
colors: COLORS,
|
||||
zIndex: 200,
|
||||
disableForReducedMotion: true,
|
||||
};
|
||||
|
||||
// Cannons sit 320px either side of center, or in the corners on narrow screens.
|
||||
const width = window.innerWidth;
|
||||
const offset = width < 640 ? width / 2 : 320;
|
||||
const leftX = 0.5 - offset / width;
|
||||
|
||||
confetti({ ...shared, angle: 60, origin: { x: leftX, y: 1 } });
|
||||
confetti({ ...shared, angle: 120, origin: { x: 1 - leftX, y: 1 } });
|
||||
}
|
||||
+18
-1
@@ -1,5 +1,6 @@
|
||||
import * as basedatefns from 'date-fns';
|
||||
import { enGB, pt } from 'date-fns/locale';
|
||||
import { type TimeOfDay, timeOfDayFromHours } from '@/config/time-of-day';
|
||||
|
||||
export * from 'date-fns';
|
||||
|
||||
@@ -14,4 +15,20 @@ const formatDistanceToNow = (...params: Parameters<typeof basedatefns.formatDist
|
||||
.replace('aproximadamente ', '')
|
||||
.replace('about ', '');
|
||||
|
||||
export { formatDistanceToNow };
|
||||
type MomentLabels = Record<TimeOfDay, string>;
|
||||
|
||||
// Executions carry only day + time-of-day granularity (each moment is stored at a fixed hour), so
|
||||
// recency is phrased in those terms rather than a false-precision elapsed time. Today and yesterday
|
||||
// name the moment; anything older falls back to day/week/month distance.
|
||||
export const formatLastExecuted = (
|
||||
date: Date,
|
||||
labels: { today: MomentLabels; yesterday: MomentLabels },
|
||||
locale: basedatefns.Locale,
|
||||
) => {
|
||||
const days = basedatefns.differenceInCalendarDays(new Date(), date);
|
||||
const moment = timeOfDayFromHours(date.getHours());
|
||||
|
||||
if (days <= 0) return labels.today[moment];
|
||||
if (days === 1) return labels.yesterday[moment];
|
||||
return formatDistanceToNow(date, { addSuffix: true, locale });
|
||||
};
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Db } from '@/db';
|
||||
import { execution, person, task } from '@/db/schema';
|
||||
|
||||
const personMaxTimes = 6;
|
||||
const taskMaxTimes = 4;
|
||||
|
||||
export const insertRandomExecutions = async (db: Db): Promise<number> => {
|
||||
const people = await db.select().from(person);
|
||||
const tasks = await db.select().from(task);
|
||||
if (!people.length || !tasks.length) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const rows: (typeof execution.$inferInsert)[] = [];
|
||||
for (let i = 0; i <= Math.random() * personMaxTimes; i++) {
|
||||
const randomPerson = people[Math.floor(Math.random() * people.length)];
|
||||
|
||||
for (let j = 0; j <= Math.random() * taskMaxTimes; j++) {
|
||||
const randomTask = tasks[Math.floor(Math.random() * tasks.length)];
|
||||
|
||||
rows.push({
|
||||
personId: randomPerson.id,
|
||||
taskId: randomTask.id,
|
||||
date: new Date(Date.now() - Math.random() * 24 * 60 * 60 * 1000),
|
||||
effort: Math.random() * 1.5 + 0.5,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await db.insert(execution).values(rows);
|
||||
return rows.length;
|
||||
};
|
||||
Reference in New Issue
Block a user