Skip to content

Commit 3b49a0e

Browse files
committed
Adds /dashboard.
1 parent 829af71 commit 3b49a0e

10 files changed

Lines changed: 616 additions & 152 deletions

File tree

apps/web/app/actions/dashboard.ts

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
'use server';
2+
import { prisma } from '@repo/prisma';
3+
import { requireAuth } from './auth';
4+
5+
interface DashboardMetrics {
6+
totalEntries: number;
7+
writingStreak: number;
8+
responseRate: number;
9+
recentActivity: number;
10+
}
11+
12+
interface EntryPreview {
13+
id: string;
14+
content: string;
15+
createdAt: Date;
16+
promptId: string;
17+
prompt: {
18+
content: string;
19+
};
20+
}
21+
22+
interface DashboardData {
23+
metrics: DashboardMetrics;
24+
recentEntries: EntryPreview[];
25+
isEmpty: boolean;
26+
}
27+
28+
/**
29+
* Calculate writing streak based on user frequency and entry dates
30+
*/
31+
async function calculateWritingStreak(userId: string): Promise<number> {
32+
const user = await prisma.user.findUnique({
33+
where: { id: userId },
34+
select: { frequency: true }
35+
});
36+
37+
const entries = await prisma.entry.findMany({
38+
where: { userId },
39+
orderBy: { createdAt: 'desc' },
40+
select: { createdAt: true }
41+
});
42+
43+
if (!entries.length || !user) return 0;
44+
45+
const intervalDays = user.frequency === 'daily' ? 1 : 7;
46+
let streak = 0;
47+
let currentDate = new Date();
48+
49+
for (const entry of entries) {
50+
const daysDiff = Math.floor(
51+
(currentDate.getTime() - entry.createdAt.getTime()) / (1000 * 60 * 60 * 24)
52+
);
53+
54+
if (daysDiff <= intervalDays * (streak + 1)) {
55+
streak++;
56+
currentDate = entry.createdAt;
57+
} else {
58+
break;
59+
}
60+
}
61+
62+
return streak;
63+
}
64+
65+
/**
66+
* Calculate response rate over last 30 days
67+
*/
68+
async function calculateResponseRate(userId: string): Promise<number> {
69+
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
70+
71+
const [totalPrompts, totalEntries] = await Promise.all([
72+
prisma.prompt.count({
73+
where: {
74+
userId,
75+
createdAt: { gte: thirtyDaysAgo }
76+
}
77+
}),
78+
prisma.entry.count({
79+
where: {
80+
userId,
81+
createdAt: { gte: thirtyDaysAgo }
82+
}
83+
})
84+
]);
85+
86+
return totalPrompts > 0 ? Math.round((totalEntries / totalPrompts) * 100) : 0;
87+
}
88+
89+
/**
90+
* Fetch all dashboard data for authenticated user
91+
*/
92+
export async function fetchDashboardData(): Promise<DashboardData> {
93+
const authResult = await requireAuth();
94+
95+
// requireAuth redirects if invalid, so if we reach here, userId exists
96+
const userId = authResult.userId!;
97+
98+
const [
99+
totalEntries,
100+
recentEntries,
101+
writingStreak,
102+
responseRate,
103+
recentActivity
104+
] = await Promise.all([
105+
// Total entries count
106+
prisma.entry.count({ where: { userId } }),
107+
108+
// Last 5 entries with prompt context
109+
prisma.entry.findMany({
110+
where: { userId },
111+
orderBy: { createdAt: 'desc' },
112+
take: 5,
113+
include: {
114+
prompt: {
115+
select: { content: true }
116+
}
117+
}
118+
}),
119+
120+
// Writing streak calculation
121+
calculateWritingStreak(userId),
122+
123+
// Response rate over 30 days
124+
calculateResponseRate(userId),
125+
126+
// Recent activity (entries in last 30 days)
127+
prisma.entry.count({
128+
where: {
129+
userId,
130+
createdAt: {
131+
gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
132+
}
133+
}
134+
})
135+
]);
136+
137+
return {
138+
metrics: {
139+
totalEntries,
140+
writingStreak,
141+
responseRate,
142+
recentActivity
143+
},
144+
recentEntries,
145+
isEmpty: totalEntries === 0
146+
};
147+
}
148+
149+
/**
150+
* Create entry snippet for previews
151+
*/
152+
export async function createEntrySnippet(content: string, maxLength: number = 150): Promise<string> {
153+
if (content.length <= maxLength) return content;
154+
155+
const truncated = content.substring(0, maxLength);
156+
const lastSpace = truncated.lastIndexOf(' ');
157+
158+
return lastSpace > 0
159+
? truncated.substring(0, lastSpace) + '...'
160+
: truncated + '...';
161+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { DashboardMetrics } from './Dashboard/DashboardMetrics';
2+
import { RecentEntries } from './Dashboard/RecentEntries';
3+
import { EmptyDashboard } from './Dashboard/EmptyDashboard';
4+
import { fetchDashboardData } from '../actions/dashboard';
5+
6+
export async function Dashboard() {
7+
let dashboardData;
8+
9+
try {
10+
dashboardData = await fetchDashboardData();
11+
} catch (error) {
12+
console.error('Failed to fetch dashboard data:', error);
13+
// Fallback to empty state if data fetch fails
14+
dashboardData = {
15+
metrics: { totalEntries: 0, writingStreak: 0, responseRate: 0, recentActivity: 0 },
16+
recentEntries: [],
17+
isEmpty: true
18+
};
19+
}
20+
21+
// Show empty state for new users
22+
if (dashboardData.isEmpty) {
23+
return <EmptyDashboard />;
24+
}
25+
26+
// Show populated dashboard
27+
return (
28+
<div className="min-h-screen bg-ps-primary-50">
29+
<div className="container mx-auto px-6 py-8">
30+
<div className="max-w-6xl mx-auto">
31+
<div className="mb-8">
32+
<h1 className="text-3xl font-bold text-ps-primary mb-2">Dashboard</h1>
33+
<p className="text-ps-secondary">Your writing journey at a glance</p>
34+
</div>
35+
36+
<div className="space-y-8">
37+
<DashboardMetrics
38+
totalEntries={dashboardData.metrics.totalEntries}
39+
writingStreak={dashboardData.metrics.writingStreak}
40+
responseRate={dashboardData.metrics.responseRate}
41+
recentActivity={dashboardData.metrics.recentActivity}
42+
/>
43+
44+
<RecentEntries entries={dashboardData.recentEntries} />
45+
</div>
46+
</div>
47+
</div>
48+
</div>
49+
);
50+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { MetricCard } from './MetricCard';
2+
3+
interface DashboardMetricsProps {
4+
totalEntries: number;
5+
writingStreak: number;
6+
responseRate: number;
7+
recentActivity: number;
8+
loading?: boolean;
9+
}
10+
11+
export function DashboardMetrics({
12+
totalEntries,
13+
writingStreak,
14+
responseRate,
15+
recentActivity,
16+
loading = false
17+
}: DashboardMetricsProps) {
18+
const isGoodStreak = writingStreak >= 3;
19+
20+
return (
21+
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
22+
<MetricCard
23+
title="Total Entries"
24+
value={totalEntries}
25+
loading={loading}
26+
/>
27+
<MetricCard
28+
title="Writing Streak"
29+
value={loading ? 0 : `${writingStreak} days`}
30+
isHighlight={!loading && isGoodStreak}
31+
loading={loading}
32+
/>
33+
<MetricCard
34+
title="Response Rate"
35+
value={loading ? 0 : `${responseRate}%`}
36+
loading={loading}
37+
/>
38+
<MetricCard
39+
title="Recent Activity"
40+
value={loading ? 0 : `${recentActivity} entries`}
41+
loading={loading}
42+
/>
43+
</div>
44+
);
45+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import Link from 'next/link';
2+
3+
export function EmptyDashboard() {
4+
return (
5+
<div className="min-h-screen bg-ps-primary-50">
6+
<div className="container mx-auto px-6 py-20">
7+
<div className="max-w-2xl mx-auto text-center">
8+
<h1 className="text-4xl font-bold text-ps-primary mb-6">
9+
Welcome to Your Journey
10+
</h1>
11+
<p className="text-xl text-ps-secondary mb-8 leading-relaxed">
12+
Ready to start reflecting? Your first thoughtful prompt is waiting
13+
for you. No pressure, no commitment—just gentle questions that help
14+
you explore your thoughts.
15+
</p>
16+
17+
<div className="bg-ps-secondary rounded-lg p-8 mb-8">
18+
<h3 className="text-lg font-semibold text-ps-primary mb-4">
19+
How it works:
20+
</h3>
21+
<div className="space-y-3 text-left text-ps-secondary">
22+
<p>📧 Receive thoughtful prompts in your inbox</p>
23+
<p>✍️ Write as much or as little as you want</p>
24+
<p>🌱 Build a sustainable reflection practice</p>
25+
</div>
26+
</div>
27+
28+
<Link
29+
href="/prompt"
30+
className="inline-block bg-ps-primary text-white px-8 py-3 rounded-lg font-semibold hover:bg-ps-primary-600 transition-colors"
31+
>
32+
Start Writing
33+
</Link>
34+
35+
<p className="text-sm text-ps-secondary-600 mt-4">
36+
Your entries are private and secure
37+
</p>
38+
</div>
39+
</div>
40+
</div>
41+
);
42+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import Link from 'next/link';
2+
import { createEntrySnippet } from '../../actions/dashboard';
3+
4+
interface EntryPreviewCardProps {
5+
id: string;
6+
content: string;
7+
createdAt: Date;
8+
promptId: string;
9+
}
10+
11+
export function EntryPreviewCard({ id, content, createdAt, promptId }: EntryPreviewCardProps) {
12+
const snippet = createEntrySnippet(content);
13+
const formattedDate = new Date(createdAt).toLocaleDateString('en-US', {
14+
month: 'short',
15+
day: 'numeric',
16+
year: 'numeric',
17+
});
18+
19+
return (
20+
<Link href={`/entry/${promptId}`} className="block">
21+
<div className="bg-ps-secondary rounded-lg p-4 shadow-sm hover:shadow-md transition-shadow">
22+
<p className="text-ps-primary text-sm mb-2 line-clamp-3">
23+
{snippet}
24+
</p>
25+
<p className="text-ps-secondary-600 text-xs">
26+
{formattedDate}
27+
</p>
28+
</div>
29+
</Link>
30+
);
31+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
interface MetricCardProps {
2+
title: string;
3+
value: string | number;
4+
isHighlight?: boolean;
5+
loading?: boolean;
6+
}
7+
8+
export function MetricCard({ title, value, isHighlight = false, loading = false }: MetricCardProps) {
9+
if (loading) {
10+
return (
11+
<div className="bg-ps-secondary rounded-lg p-6 shadow-sm">
12+
<div className="space-y-2">
13+
<div className="h-4 bg-gray-200 rounded animate-pulse"></div>
14+
<div className="h-8 bg-gray-200 rounded animate-pulse"></div>
15+
</div>
16+
</div>
17+
);
18+
}
19+
20+
return (
21+
<div className={`rounded-lg p-6 shadow-sm ${
22+
isHighlight
23+
? 'bg-green-50 border-green-200 text-green-800 border'
24+
: 'bg-ps-secondary'
25+
}`}>
26+
<h3 className={`text-sm font-medium mb-2 ${
27+
isHighlight ? 'text-green-600' : 'text-ps-secondary-600'
28+
}`}>
29+
{title}
30+
</h3>
31+
<p className={`text-2xl font-bold ${
32+
isHighlight ? 'text-green-800' : 'text-ps-primary'
33+
}`}>
34+
{value}
35+
</p>
36+
</div>
37+
);
38+
}

0 commit comments

Comments
 (0)