Skip to content

Commit e619c1a

Browse files
dcbrscastiel
andauthored
Add basic activity log (#141)
* Add basic activity log * Add database migration * Fix layout * Fix types --------- Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
1 parent 10e13d1 commit e619c1a

File tree

17 files changed

+400
-29
lines changed

17 files changed

+400
-29
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
-- CreateEnum
2+
CREATE TYPE "ActivityType" AS ENUM ('UPDATE_GROUP', 'CREATE_EXPENSE', 'UPDATE_EXPENSE', 'DELETE_EXPENSE');
3+
4+
-- CreateTable
5+
CREATE TABLE "Activity" (
6+
"id" TEXT NOT NULL,
7+
"groupId" TEXT NOT NULL,
8+
"time" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
9+
"activityType" "ActivityType" NOT NULL,
10+
"participantId" TEXT,
11+
"expenseId" TEXT,
12+
"data" TEXT,
13+
14+
CONSTRAINT "Activity_pkey" PRIMARY KEY ("id")
15+
);
16+
17+
-- AddForeignKey
18+
ALTER TABLE "Activity" ADD CONSTRAINT "Activity_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "Group"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

prisma/schema.prisma

+19
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ model Group {
1717
currency String @default("$")
1818
participants Participant[]
1919
expenses Expense[]
20+
activities Activity[]
2021
createdAt DateTime @default(now())
2122
}
2223

@@ -80,3 +81,21 @@ model ExpensePaidFor {
8081
8182
@@id([expenseId, participantId])
8283
}
84+
85+
model Activity {
86+
id String @id
87+
group Group @relation(fields: [groupId], references: [id])
88+
groupId String
89+
time DateTime @default(now())
90+
activityType ActivityType
91+
participantId String?
92+
expenseId String?
93+
data String?
94+
}
95+
96+
enum ActivityType {
97+
UPDATE_GROUP
98+
CREATE_EXPENSE
99+
UPDATE_EXPENSE
100+
DELETE_EXPENSE
101+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
'use client'
2+
import { Button } from '@/components/ui/button'
3+
import { getGroupExpenses } from '@/lib/api'
4+
import { DateTimeStyle, cn, formatDate } from '@/lib/utils'
5+
import { Activity, ActivityType, Participant } from '@prisma/client'
6+
import { ChevronRight } from 'lucide-react'
7+
import Link from 'next/link'
8+
import { useRouter } from 'next/navigation'
9+
10+
type Props = {
11+
groupId: string
12+
activity: Activity
13+
participant?: Participant
14+
expense?: Awaited<ReturnType<typeof getGroupExpenses>>[number]
15+
dateStyle: DateTimeStyle
16+
}
17+
18+
function getSummary(activity: Activity, participantName?: string) {
19+
const participant = participantName ?? 'Someone'
20+
const expense = activity.data ?? ''
21+
if (activity.activityType == ActivityType.UPDATE_GROUP) {
22+
return (
23+
<>
24+
Group settings were modified by <strong>{participant}</strong>
25+
</>
26+
)
27+
} else if (activity.activityType == ActivityType.CREATE_EXPENSE) {
28+
return (
29+
<>
30+
Expense <em>&ldquo;{expense}&rdquo;</em> created by{' '}
31+
<strong>{participant}</strong>.
32+
</>
33+
)
34+
} else if (activity.activityType == ActivityType.UPDATE_EXPENSE) {
35+
return (
36+
<>
37+
Expense <em>&ldquo;{expense}&rdquo;</em> updated by{' '}
38+
<strong>{participant}</strong>.
39+
</>
40+
)
41+
} else if (activity.activityType == ActivityType.DELETE_EXPENSE) {
42+
return (
43+
<>
44+
Expense <em>&ldquo;{expense}&rdquo;</em> deleted by{' '}
45+
<strong>{participant}</strong>.
46+
</>
47+
)
48+
}
49+
}
50+
51+
export function ActivityItem({
52+
groupId,
53+
activity,
54+
participant,
55+
expense,
56+
dateStyle,
57+
}: Props) {
58+
const router = useRouter()
59+
60+
const expenseExists = expense !== undefined
61+
const summary = getSummary(activity, participant?.name)
62+
63+
return (
64+
<div
65+
className={cn(
66+
'flex justify-between sm:rounded-lg px-2 sm:pr-1 sm:pl-2 py-2 text-sm hover:bg-accent gap-1 items-stretch',
67+
expenseExists && 'cursor-pointer',
68+
)}
69+
onClick={() => {
70+
if (expenseExists) {
71+
router.push(`/groups/${groupId}/expenses/${activity.expenseId}/edit`)
72+
}
73+
}}
74+
>
75+
<div className="flex flex-col justify-between items-start">
76+
{dateStyle !== undefined && (
77+
<div className="mt-1 text-xs/5 text-muted-foreground">
78+
{formatDate(activity.time, { dateStyle })}
79+
</div>
80+
)}
81+
<div className="my-1 text-xs/5 text-muted-foreground">
82+
{formatDate(activity.time, { timeStyle: 'short' })}
83+
</div>
84+
</div>
85+
<div className="flex-1">
86+
<div className="m-1">{summary}</div>
87+
</div>
88+
{expenseExists && (
89+
<Button
90+
size="icon"
91+
variant="link"
92+
className="self-center hidden sm:flex w-5 h-5"
93+
asChild
94+
>
95+
<Link href={`/groups/${groupId}/expenses/${activity.expenseId}/edit`}>
96+
<ChevronRight className="w-4 h-4" />
97+
</Link>
98+
</Button>
99+
)}
100+
</div>
101+
)
102+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { ActivityItem } from '@/app/groups/[groupId]/activity/activity-item'
2+
import { getGroupExpenses } from '@/lib/api'
3+
import { Activity, Participant } from '@prisma/client'
4+
import dayjs, { type Dayjs } from 'dayjs'
5+
6+
type Props = {
7+
groupId: string
8+
participants: Participant[]
9+
expenses: Awaited<ReturnType<typeof getGroupExpenses>>
10+
activities: Activity[]
11+
}
12+
13+
const DATE_GROUPS = {
14+
TODAY: 'Today',
15+
YESTERDAY: 'Yesterday',
16+
EARLIER_THIS_WEEK: 'Earlier this week',
17+
LAST_WEEK: 'Last week',
18+
EARLIER_THIS_MONTH: 'Earlier this month',
19+
LAST_MONTH: 'Last month',
20+
EARLIER_THIS_YEAR: 'Earlier this year',
21+
LAST_YEAR: 'Last year',
22+
OLDER: 'Older',
23+
}
24+
25+
function getDateGroup(date: Dayjs, today: Dayjs) {
26+
if (today.isSame(date, 'day')) {
27+
return DATE_GROUPS.TODAY
28+
} else if (today.subtract(1, 'day').isSame(date, 'day')) {
29+
return DATE_GROUPS.YESTERDAY
30+
} else if (today.isSame(date, 'week')) {
31+
return DATE_GROUPS.EARLIER_THIS_WEEK
32+
} else if (today.subtract(1, 'week').isSame(date, 'week')) {
33+
return DATE_GROUPS.LAST_WEEK
34+
} else if (today.isSame(date, 'month')) {
35+
return DATE_GROUPS.EARLIER_THIS_MONTH
36+
} else if (today.subtract(1, 'month').isSame(date, 'month')) {
37+
return DATE_GROUPS.LAST_MONTH
38+
} else if (today.isSame(date, 'year')) {
39+
return DATE_GROUPS.EARLIER_THIS_YEAR
40+
} else if (today.subtract(1, 'year').isSame(date, 'year')) {
41+
return DATE_GROUPS.LAST_YEAR
42+
} else {
43+
return DATE_GROUPS.OLDER
44+
}
45+
}
46+
47+
function getGroupedActivitiesByDate(activities: Activity[]) {
48+
const today = dayjs()
49+
return activities.reduce(
50+
(result: { [key: string]: Activity[] }, activity: Activity) => {
51+
const activityGroup = getDateGroup(dayjs(activity.time), today)
52+
result[activityGroup] = result[activityGroup] ?? []
53+
result[activityGroup].push(activity)
54+
return result
55+
},
56+
{},
57+
)
58+
}
59+
60+
export function ActivityList({
61+
groupId,
62+
participants,
63+
expenses,
64+
activities,
65+
}: Props) {
66+
const groupedActivitiesByDate = getGroupedActivitiesByDate(activities)
67+
68+
return activities.length > 0 ? (
69+
<>
70+
{Object.values(DATE_GROUPS).map((dateGroup: string) => {
71+
let groupActivities = groupedActivitiesByDate[dateGroup]
72+
if (!groupActivities || groupActivities.length === 0) return null
73+
const dateStyle =
74+
dateGroup == DATE_GROUPS.TODAY || dateGroup == DATE_GROUPS.YESTERDAY
75+
? undefined
76+
: 'medium'
77+
78+
return (
79+
<div key={dateGroup}>
80+
<div
81+
className={
82+
'text-muted-foreground text-xs py-1 font-semibold sticky top-16 bg-white dark:bg-[#1b1917]'
83+
}
84+
>
85+
{dateGroup}
86+
</div>
87+
{groupActivities.map((activity: Activity) => {
88+
const participant =
89+
activity.participantId !== null
90+
? participants.find((p) => p.id === activity.participantId)
91+
: undefined
92+
const expense =
93+
activity.expenseId !== null
94+
? expenses.find((e) => e.id === activity.expenseId)
95+
: undefined
96+
return (
97+
<ActivityItem
98+
key={activity.id}
99+
{...{ groupId, activity, participant, expense, dateStyle }}
100+
/>
101+
)
102+
})}
103+
</div>
104+
)
105+
})}
106+
</>
107+
) : (
108+
<p className="px-6 text-sm py-6">
109+
There is not yet any activity in your group.
110+
</p>
111+
)
112+
}
+51
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { cached } from '@/app/cached-functions'
2+
import { ActivityList } from '@/app/groups/[groupId]/activity/activity-list'
3+
import {
4+
Card,
5+
CardContent,
6+
CardDescription,
7+
CardHeader,
8+
CardTitle,
9+
} from '@/components/ui/card'
10+
import { getActivities, getGroupExpenses } from '@/lib/api'
11+
import { Metadata } from 'next'
12+
import { notFound } from 'next/navigation'
13+
14+
export const metadata: Metadata = {
15+
title: 'Activity',
16+
}
17+
18+
export default async function ActivityPage({
19+
params: { groupId },
20+
}: {
21+
params: { groupId: string }
22+
}) {
23+
const group = await cached.getGroup(groupId)
24+
if (!group) notFound()
25+
26+
const expenses = await getGroupExpenses(groupId)
27+
const activities = await getActivities(groupId)
28+
29+
return (
30+
<>
31+
<Card className="mb-4">
32+
<CardHeader>
33+
<CardTitle>Activity</CardTitle>
34+
<CardDescription>
35+
Overview of all activity in this group.
36+
</CardDescription>
37+
</CardHeader>
38+
<CardContent className="flex flex-col space-y-4">
39+
<ActivityList
40+
{...{
41+
groupId,
42+
participants: group.participants,
43+
expenses,
44+
activities,
45+
}}
46+
/>
47+
</CardContent>
48+
</Card>
49+
</>
50+
)
51+
}

src/app/groups/[groupId]/edit/page.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,10 @@ export default async function EditGroupPage({
1717
const group = await cached.getGroup(groupId)
1818
if (!group) notFound()
1919

20-
async function updateGroupAction(values: unknown) {
20+
async function updateGroupAction(values: unknown, participantId?: string) {
2121
'use server'
2222
const groupFormValues = groupFormSchema.parse(values)
23-
const group = await updateGroup(groupId, groupFormValues)
23+
const group = await updateGroup(groupId, groupFormValues, participantId)
2424
redirect(`/groups/${group.id}`)
2525
}
2626

src/app/groups/[groupId]/expenses/[expenseId]/edit/page.tsx

+4-4
Original file line numberDiff line numberDiff line change
@@ -27,16 +27,16 @@ export default async function EditExpensePage({
2727
const expense = await getExpense(groupId, expenseId)
2828
if (!expense) notFound()
2929

30-
async function updateExpenseAction(values: unknown) {
30+
async function updateExpenseAction(values: unknown, participantId?: string) {
3131
'use server'
3232
const expenseFormValues = expenseFormSchema.parse(values)
33-
await updateExpense(groupId, expenseId, expenseFormValues)
33+
await updateExpense(groupId, expenseId, expenseFormValues, participantId)
3434
redirect(`/groups/${groupId}`)
3535
}
3636

37-
async function deleteExpenseAction() {
37+
async function deleteExpenseAction(participantId?: string) {
3838
'use server'
39-
await deleteExpense(expenseId)
39+
await deleteExpense(groupId, expenseId, participantId)
4040
redirect(`/groups/${groupId}`)
4141
}
4242

src/app/groups/[groupId]/expenses/create-from-receipt-button.tsx

+4-4
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import {
2626
import { ToastAction } from '@/components/ui/toast'
2727
import { useToast } from '@/components/ui/use-toast'
2828
import { useMediaQuery } from '@/lib/hooks'
29-
import { formatCurrency, formatExpenseDate, formatFileSize } from '@/lib/utils'
29+
import { formatCurrency, formatDate, formatFileSize } from '@/lib/utils'
3030
import { Category } from '@prisma/client'
3131
import { ChevronRight, FileQuestion, Loader2, Receipt } from 'lucide-react'
3232
import { getImageData, usePresignedUpload } from 'next-s3-upload'
@@ -212,9 +212,9 @@ export function CreateFromReceiptButton({
212212
<div>
213213
{receiptInfo ? (
214214
receiptInfo.date ? (
215-
formatExpenseDate(
216-
new Date(`${receiptInfo?.date}T12:00:00.000Z`),
217-
)
215+
formatDate(new Date(`${receiptInfo?.date}T12:00:00.000Z`), {
216+
dateStyle: 'medium',
217+
})
218218
) : (
219219
<Unknown />
220220
)

src/app/groups/[groupId]/expenses/create/page.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,10 @@ export default async function ExpensePage({
2020
const group = await cached.getGroup(groupId)
2121
if (!group) notFound()
2222

23-
async function createExpenseAction(values: unknown) {
23+
async function createExpenseAction(values: unknown, participantId?: string) {
2424
'use server'
2525
const expenseFormValues = expenseFormSchema.parse(values)
26-
await createExpense(expenseFormValues, groupId)
26+
await createExpense(expenseFormValues, groupId, participantId)
2727
redirect(`/groups/${groupId}`)
2828
}
2929

0 commit comments

Comments
 (0)