Compare commits

...

2 Commits

Author SHA1 Message Date
luis 3c9d7e4e9d Do things in the demo periocically
Build and Deploy / build-deploy (push) Successful in 1m37s
2026-07-14 14:48:11 +01:00
luis 817d5eb7f1 Update button styles and add confetti 2026-07-14 14:30:40 +01:00
20 changed files with 288 additions and 62 deletions
+28
View File
@@ -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
BIN
View File
Binary file not shown.
+3
View File
@@ -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",
+14
View File
@@ -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);
-3
View File
@@ -9,7 +9,6 @@ import {
createSession,
deleteSessionCookie,
generateSessionToken,
getCurrentUser,
invalidateSession,
readSessionCookie,
setSessionCookie,
@@ -53,5 +52,3 @@ export const logout = async () => {
}
redirect('/');
};
export { getCurrentUser };
+5 -1
View File
@@ -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 -27
View File
@@ -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
View File
@@ -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
View File
@@ -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>
-3
View File
@@ -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' })}>
+1 -1
View File
@@ -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(
+16 -5
View File
@@ -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>
+1 -3
View File
@@ -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
View File
@@ -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 -2
View File
@@ -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>
);
};
+12
View File
@@ -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",
+12
View File
@@ -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",
+30
View File
@@ -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
View File
@@ -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 });
};
+32
View File
@@ -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;
};