-
-
Notifications
You must be signed in to change notification settings - Fork 180
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* Add basic activity log * Add database migration * Fix layout * Fix types --------- Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
- Loading branch information
Showing
17 changed files
with
400 additions
and
29 deletions.
There are no files selected for viewing
18 changes: 18 additions & 0 deletions
18
prisma/migrations/20240414135355_add_activity_log/migration.sql
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
-- CreateEnum | ||
CREATE TYPE "ActivityType" AS ENUM ('UPDATE_GROUP', 'CREATE_EXPENSE', 'UPDATE_EXPENSE', 'DELETE_EXPENSE'); | ||
|
||
-- CreateTable | ||
CREATE TABLE "Activity" ( | ||
"id" TEXT NOT NULL, | ||
"groupId" TEXT NOT NULL, | ||
"time" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||
"activityType" "ActivityType" NOT NULL, | ||
"participantId" TEXT, | ||
"expenseId" TEXT, | ||
"data" TEXT, | ||
|
||
CONSTRAINT "Activity_pkey" PRIMARY KEY ("id") | ||
); | ||
|
||
-- AddForeignKey | ||
ALTER TABLE "Activity" ADD CONSTRAINT "Activity_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "Group"("id") ON DELETE RESTRICT ON UPDATE CASCADE; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,102 @@ | ||
'use client' | ||
import { Button } from '@/components/ui/button' | ||
import { getGroupExpenses } from '@/lib/api' | ||
import { DateTimeStyle, cn, formatDate } from '@/lib/utils' | ||
import { Activity, ActivityType, Participant } from '@prisma/client' | ||
import { ChevronRight } from 'lucide-react' | ||
import Link from 'next/link' | ||
import { useRouter } from 'next/navigation' | ||
|
||
type Props = { | ||
groupId: string | ||
activity: Activity | ||
participant?: Participant | ||
expense?: Awaited<ReturnType<typeof getGroupExpenses>>[number] | ||
dateStyle: DateTimeStyle | ||
} | ||
|
||
function getSummary(activity: Activity, participantName?: string) { | ||
const participant = participantName ?? 'Someone' | ||
const expense = activity.data ?? '' | ||
if (activity.activityType == ActivityType.UPDATE_GROUP) { | ||
return ( | ||
<> | ||
Group settings were modified by <strong>{participant}</strong> | ||
</> | ||
) | ||
} else if (activity.activityType == ActivityType.CREATE_EXPENSE) { | ||
return ( | ||
<> | ||
Expense <em>“{expense}”</em> created by{' '} | ||
<strong>{participant}</strong>. | ||
</> | ||
) | ||
} else if (activity.activityType == ActivityType.UPDATE_EXPENSE) { | ||
return ( | ||
<> | ||
Expense <em>“{expense}”</em> updated by{' '} | ||
<strong>{participant}</strong>. | ||
</> | ||
) | ||
} else if (activity.activityType == ActivityType.DELETE_EXPENSE) { | ||
return ( | ||
<> | ||
Expense <em>“{expense}”</em> deleted by{' '} | ||
<strong>{participant}</strong>. | ||
</> | ||
) | ||
} | ||
} | ||
|
||
export function ActivityItem({ | ||
groupId, | ||
activity, | ||
participant, | ||
expense, | ||
dateStyle, | ||
}: Props) { | ||
const router = useRouter() | ||
|
||
const expenseExists = expense !== undefined | ||
const summary = getSummary(activity, participant?.name) | ||
|
||
return ( | ||
<div | ||
className={cn( | ||
'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', | ||
expenseExists && 'cursor-pointer', | ||
)} | ||
onClick={() => { | ||
if (expenseExists) { | ||
router.push(`/groups/${groupId}/expenses/${activity.expenseId}/edit`) | ||
} | ||
}} | ||
> | ||
<div className="flex flex-col justify-between items-start"> | ||
{dateStyle !== undefined && ( | ||
<div className="mt-1 text-xs/5 text-muted-foreground"> | ||
{formatDate(activity.time, { dateStyle })} | ||
</div> | ||
)} | ||
<div className="my-1 text-xs/5 text-muted-foreground"> | ||
{formatDate(activity.time, { timeStyle: 'short' })} | ||
</div> | ||
</div> | ||
<div className="flex-1"> | ||
<div className="m-1">{summary}</div> | ||
</div> | ||
{expenseExists && ( | ||
<Button | ||
size="icon" | ||
variant="link" | ||
className="self-center hidden sm:flex w-5 h-5" | ||
asChild | ||
> | ||
<Link href={`/groups/${groupId}/expenses/${activity.expenseId}/edit`}> | ||
<ChevronRight className="w-4 h-4" /> | ||
</Link> | ||
</Button> | ||
)} | ||
</div> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,112 @@ | ||
import { ActivityItem } from '@/app/groups/[groupId]/activity/activity-item' | ||
import { getGroupExpenses } from '@/lib/api' | ||
import { Activity, Participant } from '@prisma/client' | ||
import dayjs, { type Dayjs } from 'dayjs' | ||
|
||
type Props = { | ||
groupId: string | ||
participants: Participant[] | ||
expenses: Awaited<ReturnType<typeof getGroupExpenses>> | ||
activities: Activity[] | ||
} | ||
|
||
const DATE_GROUPS = { | ||
TODAY: 'Today', | ||
YESTERDAY: 'Yesterday', | ||
EARLIER_THIS_WEEK: 'Earlier this week', | ||
LAST_WEEK: 'Last week', | ||
EARLIER_THIS_MONTH: 'Earlier this month', | ||
LAST_MONTH: 'Last month', | ||
EARLIER_THIS_YEAR: 'Earlier this year', | ||
LAST_YEAR: 'Last year', | ||
OLDER: 'Older', | ||
} | ||
|
||
function getDateGroup(date: Dayjs, today: Dayjs) { | ||
if (today.isSame(date, 'day')) { | ||
return DATE_GROUPS.TODAY | ||
} else if (today.subtract(1, 'day').isSame(date, 'day')) { | ||
return DATE_GROUPS.YESTERDAY | ||
} else if (today.isSame(date, 'week')) { | ||
return DATE_GROUPS.EARLIER_THIS_WEEK | ||
} else if (today.subtract(1, 'week').isSame(date, 'week')) { | ||
return DATE_GROUPS.LAST_WEEK | ||
} else if (today.isSame(date, 'month')) { | ||
return DATE_GROUPS.EARLIER_THIS_MONTH | ||
} else if (today.subtract(1, 'month').isSame(date, 'month')) { | ||
return DATE_GROUPS.LAST_MONTH | ||
} else if (today.isSame(date, 'year')) { | ||
return DATE_GROUPS.EARLIER_THIS_YEAR | ||
} else if (today.subtract(1, 'year').isSame(date, 'year')) { | ||
return DATE_GROUPS.LAST_YEAR | ||
} else { | ||
return DATE_GROUPS.OLDER | ||
} | ||
} | ||
|
||
function getGroupedActivitiesByDate(activities: Activity[]) { | ||
const today = dayjs() | ||
return activities.reduce( | ||
(result: { [key: string]: Activity[] }, activity: Activity) => { | ||
const activityGroup = getDateGroup(dayjs(activity.time), today) | ||
result[activityGroup] = result[activityGroup] ?? [] | ||
result[activityGroup].push(activity) | ||
return result | ||
}, | ||
{}, | ||
) | ||
} | ||
|
||
export function ActivityList({ | ||
groupId, | ||
participants, | ||
expenses, | ||
activities, | ||
}: Props) { | ||
const groupedActivitiesByDate = getGroupedActivitiesByDate(activities) | ||
|
||
return activities.length > 0 ? ( | ||
<> | ||
{Object.values(DATE_GROUPS).map((dateGroup: string) => { | ||
let groupActivities = groupedActivitiesByDate[dateGroup] | ||
if (!groupActivities || groupActivities.length === 0) return null | ||
const dateStyle = | ||
dateGroup == DATE_GROUPS.TODAY || dateGroup == DATE_GROUPS.YESTERDAY | ||
? undefined | ||
: 'medium' | ||
|
||
return ( | ||
<div key={dateGroup}> | ||
<div | ||
className={ | ||
'text-muted-foreground text-xs py-1 font-semibold sticky top-16 bg-white dark:bg-[#1b1917]' | ||
} | ||
> | ||
{dateGroup} | ||
</div> | ||
{groupActivities.map((activity: Activity) => { | ||
const participant = | ||
activity.participantId !== null | ||
? participants.find((p) => p.id === activity.participantId) | ||
: undefined | ||
const expense = | ||
activity.expenseId !== null | ||
? expenses.find((e) => e.id === activity.expenseId) | ||
: undefined | ||
return ( | ||
<ActivityItem | ||
key={activity.id} | ||
{...{ groupId, activity, participant, expense, dateStyle }} | ||
/> | ||
) | ||
})} | ||
</div> | ||
) | ||
})} | ||
</> | ||
) : ( | ||
<p className="px-6 text-sm py-6"> | ||
There is not yet any activity in your group. | ||
</p> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,51 @@ | ||
import { cached } from '@/app/cached-functions' | ||
import { ActivityList } from '@/app/groups/[groupId]/activity/activity-list' | ||
import { | ||
Card, | ||
CardContent, | ||
CardDescription, | ||
CardHeader, | ||
CardTitle, | ||
} from '@/components/ui/card' | ||
import { getActivities, getGroupExpenses } from '@/lib/api' | ||
import { Metadata } from 'next' | ||
import { notFound } from 'next/navigation' | ||
|
||
export const metadata: Metadata = { | ||
title: 'Activity', | ||
} | ||
|
||
export default async function ActivityPage({ | ||
params: { groupId }, | ||
}: { | ||
params: { groupId: string } | ||
}) { | ||
const group = await cached.getGroup(groupId) | ||
if (!group) notFound() | ||
|
||
const expenses = await getGroupExpenses(groupId) | ||
const activities = await getActivities(groupId) | ||
|
||
return ( | ||
<> | ||
<Card className="mb-4"> | ||
<CardHeader> | ||
<CardTitle>Activity</CardTitle> | ||
<CardDescription> | ||
Overview of all activity in this group. | ||
</CardDescription> | ||
</CardHeader> | ||
<CardContent className="flex flex-col space-y-4"> | ||
<ActivityList | ||
{...{ | ||
groupId, | ||
participants: group.participants, | ||
expenses, | ||
activities, | ||
}} | ||
/> | ||
</CardContent> | ||
</Card> | ||
</> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.