Skip to content

Commit dde10eb

Browse files
authored
feat: support daily pulse (#103)
## Summary by Sourcery Add documentation for a flexible, backward-compatible reporting schema that supports daily, weekly, ad-hoc, and custom submission periods Documentation: - Introduce a design doc outlining generalized submission_periods, templates, template_questions, submission_period_users, and reminder_logs - Detail admin workflows for creating templates, assigning users, and tracking submissions - Provide a migration plan to coexist with the current weekly pulse system - Outline automation and scheduling strategies for recurring daily and weekly reports <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Introduced flexible reporting supporting daily, ad-hoc, and custom periods alongside weekly check-ins. - Added admin UI for creating and managing follow-up templates, participants, schedules, and reminders. - Launched detailed analytics and response tracking dashboards for follow-ups. - Released "Daily Pulse" UI with daily check-in forms, calendar view, and monthly history. - Enhanced navigation with separate "My Weekly Pulse" and "My Daily Pulse" menus and sidebar items. - Added automated scheduling and assignment of recurring daily submission periods via a new cron job. - Added command palette and popover UI components for improved user interaction. - Improved dialog components with enhanced styling and accessibility features. - **Bug Fixes** - Improved form validation and error handling in follow-up and daily pulse workflows. - **Chores** - Updated dependencies and cron job configurations to support new scheduling features. - **Documentation** - Provided comprehensive documentation on the new reporting schema and admin workflows. - **Tests** - Added unit and integration tests for recurring schedule utilities and API endpoints. - **Style** - Enhanced UI components for clarity, accessibility, and consistent styling. - **Refactor** - Reorganized components and abstractions for improved maintainability and extensibility. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 8474bdc commit dde10eb

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

47 files changed

+4926
-256
lines changed

docs/daily-and-weekly-report-schema-refactor.md

Lines changed: 720 additions & 0 deletions
Large diffs are not rendered by default.

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"@radix-ui/react-dialog": "^1.1.11",
3232
"@radix-ui/react-dropdown-menu": "^2.1.12",
3333
"@radix-ui/react-label": "^2.1.6",
34+
"@radix-ui/react-popover": "^1.1.14",
3435
"@radix-ui/react-scroll-area": "^1.2.6",
3536
"@radix-ui/react-select": "^2.2.2",
3637
"@radix-ui/react-separator": "^1.1.6",
@@ -47,6 +48,7 @@
4748
"add": "^2.0.6",
4849
"class-variance-authority": "^0.7.1",
4950
"clsx": "^2.1.1",
51+
"cmdk": "^1.1.1",
5052
"date-fns": "^4.1.0",
5153
"dompurify": "^3.2.6",
5254
"framer-motion": "^12.10.0",
@@ -62,7 +64,8 @@
6264
"recharts": "^2.15.3",
6365
"resend": "^4.4.1",
6466
"tailwind-merge": "^3.2.0",
65-
"uuid": "^11.1.0"
67+
"uuid": "^11.1.0",
68+
"zod": "^3.25.57"
6669
},
6770
"devDependencies": {
6871
"@eslint/eslintrc": "^3.3.1",

pnpm-lock.yaml

Lines changed: 310 additions & 83 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { createClient } from '@/utils/supabase/client';
2+
3+
// Fetch all periods for today for a user
4+
export async function fetchTodayPeriods(userId: string, nowStr: string) {
5+
const supabase = createClient();
6+
return supabase
7+
.from('submission_period_users')
8+
.select('*, submission_periods!inner(*)')
9+
.eq('user_id', userId)
10+
.eq('submission_periods.period_type', 'daily')
11+
.lte('submission_periods.start_date', nowStr)
12+
.gte('submission_periods.end_date', nowStr);
13+
}
14+
15+
// Fetch all questions for a set of template IDs
16+
export async function fetchQuestionsByTemplateIds(templateIds: string[]) {
17+
if (!templateIds.length) return { data: [], error: null };
18+
const supabase = createClient();
19+
return supabase
20+
.from('template_questions')
21+
.select('template_id, questions(*)')
22+
.in('template_id', templateIds)
23+
.order('display_order', { ascending: true });
24+
}
25+
26+
// Fetch a template by ID
27+
export async function fetchTemplateById(templateId: string) {
28+
const supabase = createClient();
29+
return supabase
30+
.from('templates')
31+
.select('id, name, description')
32+
.eq('id', templateId)
33+
.single();
34+
}
35+
36+
// Fetch a submission for a user, period, and type
37+
export async function fetchSubmission(userId: string, periodId: string, type: string) {
38+
const supabase = createClient();
39+
return supabase
40+
.from('submissions')
41+
.select('*')
42+
.eq('user_id', userId)
43+
.eq('submission_period_id', periodId)
44+
.eq('type', type)
45+
.maybeSingle();
46+
}
47+
48+
// Fetch all periods for the current month for a user
49+
export async function fetchMonthPeriods(userId: string, monthStartStr: string, monthEndStr: string) {
50+
const supabase = createClient();
51+
return supabase
52+
.from('submission_period_users')
53+
.select('*, submission_periods!inner(*)')
54+
.eq('user_id', userId)
55+
.eq('submission_periods.period_type', 'daily')
56+
.gte('submission_periods.start_date', monthStartStr)
57+
.lte('submission_periods.end_date', monthEndStr);
58+
}
59+
60+
// Fetch all templates by IDs
61+
export async function fetchTemplatesByIds(templateIds: string[]) {
62+
if (!templateIds.length) return { data: [], error: null };
63+
const supabase = createClient();
64+
return supabase
65+
.from('templates')
66+
.select('id, name, description')
67+
.in('id', templateIds);
68+
}
69+
70+
// Fetch all submissions for a user, type, and period IDs
71+
export async function fetchSubmissions(userId: string, type: string, periodIds: string[]) {
72+
const supabase = createClient();
73+
return supabase
74+
.from('submissions')
75+
.select('*')
76+
.eq('user_id', userId)
77+
.eq('type', type)
78+
.in('submission_period_id', periodIds);
79+
}
80+
81+
// Fetch answers for a submission
82+
export async function fetchSubmissionAnswers(submissionId: string) {
83+
const supabase = createClient();
84+
return supabase
85+
.from('submission_answers')
86+
.select('*')
87+
.eq('submission_id', submissionId);
88+
}
89+
90+
// Refetch all submissions for the month for a user
91+
export async function refetchMonthSubmissions(userId: string, periodIds: string[]) {
92+
const supabase = createClient();
93+
return supabase
94+
.from('submissions')
95+
.select('*')
96+
.eq('user_id', userId)
97+
.eq('type', 'daily')
98+
.in('submission_period_id', periodIds);
99+
}
100+
101+
102+
export async function submitDailyPulse({
103+
userId,
104+
periodId,
105+
questions,
106+
form,
107+
}: {
108+
userId: string;
109+
periodId: string | number;
110+
questions: { id: string }[];
111+
form: Record<string, string | string[]>;
112+
}) {
113+
const supabase = createClient();
114+
// 1. Insert submission
115+
const { data: submission, error: submissionError } = await supabase
116+
.from('submissions')
117+
.insert({
118+
user_id: userId,
119+
submission_period_id: periodId,
120+
submitted_at: new Date().toISOString(),
121+
type: 'daily',
122+
})
123+
.select()
124+
.single();
125+
if (submissionError || !submission) {
126+
return { error: 'Failed to submit check-in.' };
127+
}
128+
// 2. Insert answers
129+
const answers = questions.map((q) => {
130+
let answer = form[q.id];
131+
if (Array.isArray(answer)) {
132+
answer = JSON.stringify(answer);
133+
}
134+
return {
135+
submission_id: submission.id,
136+
question_id: q.id,
137+
answer: answer || '',
138+
};
139+
});
140+
const { error: answersError } = await supabase
141+
.from('submission_answers')
142+
.insert(answers);
143+
if (answersError) {
144+
return { error: 'Failed to save answers.' };
145+
}
146+
return { success: true };
147+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import React from 'react';
2+
import type { SubmissionPeriod, Submission } from '@/types/followup';
3+
4+
5+
interface DailyPulseCalendarProps {
6+
monthDays: Date[];
7+
periodByDate: Record<string, SubmissionPeriod[]>;
8+
monthSubmissions: Submission[];
9+
todayUTC: string;
10+
}
11+
12+
// Helper to determine the status for a given day with multiple periods
13+
function getDayStatus({ date, key, periods, monthSubmissions, todayUTC }: {
14+
date: Date;
15+
key: string;
16+
periods: SubmissionPeriod[];
17+
monthSubmissions: Submission[];
18+
todayUTC: string;
19+
}): 'submitted' | 'missed' | 'not_assigned' | 'not_submitted' {
20+
if (!periods || periods.length === 0) return 'not_assigned';
21+
let hasMissed = false;
22+
let hasNotSubmitted = false;
23+
let allSubmitted = true;
24+
for (const period of periods) {
25+
const submission = monthSubmissions.find(
26+
(s) => s.submission_period_id === period.id
27+
);
28+
const isToday = key === todayUTC;
29+
if (submission) continue;
30+
allSubmitted = false;
31+
if (date < new Date(todayUTC)) hasMissed = true;
32+
else if (isToday || date >= new Date(todayUTC)) hasNotSubmitted = true;
33+
}
34+
if (allSubmitted) return 'submitted';
35+
if (hasMissed) return 'missed';
36+
if (hasNotSubmitted) return 'not_submitted';
37+
return 'not_assigned';
38+
}
39+
40+
const DailyPulseCalendar: React.FC<DailyPulseCalendarProps> = ({ monthDays, periodByDate, monthSubmissions, todayUTC }) => {
41+
42+
console.log('periodByDate', periodByDate);
43+
return (
44+
<div className="mb-10">
45+
<h2 className="font-bold mb-3 text-lg text-gray-800">This Month&apos;s Check-in Overview</h2>
46+
<div className="bg-white rounded-xl shadow p-4">
47+
<div className="grid grid-cols-7 gap-2 text-left text-xs font-semibold text-gray-500 mb-2 pl-2">
48+
<div>Sun</div><div>Mon</div><div>Tue</div><div>Wed</div><div>Thu</div><div>Fri</div><div>Sat</div>
49+
</div>
50+
<div className="grid grid-cols-7 gap-2">
51+
{/* Empty cells for first week */}
52+
{Array.from({ length: monthDays[0].getDay() }).map((_, i) => (
53+
<div key={i}></div>
54+
))}
55+
{/* Days of month */}
56+
{monthDays.map((date) => {
57+
const day = date.getDate();
58+
const key = date.toISOString().slice(0, 10);
59+
const periods = periodByDate[key] || [];
60+
const status = getDayStatus({ date, key, periods, monthSubmissions, todayUTC });
61+
const isToday = key === todayUTC;
62+
return (
63+
<div
64+
key={day}
65+
className={`flex items-center justify-center w-9 h-9 rounded-full font-bold text-sm leading-none text-center cursor-pointer transition-all
66+
${status === 'submitted' ? 'bg-green-500 text-white' : ''}
67+
${status === 'missed' ? 'bg-red-500 text-white' : ''}
68+
${status === 'not_assigned' ? 'bg-gray-300 text-gray-500' : ''}
69+
${status === 'not_submitted' ? 'bg-yellow-400 text-white' : ''}
70+
${isToday ? 'ring-2 ring-blue-400 border-2 border-blue-400' : ''}
71+
${status === 'not_assigned' ? 'opacity-50' : 'hover:scale-110'}
72+
`}
73+
title={status.charAt(0).toUpperCase() + status.slice(1)}
74+
>
75+
{day}
76+
</div>
77+
);
78+
})}
79+
</div>
80+
<div className="flex gap-4 mt-4 text-xs">
81+
<div className="flex items-center gap-1"><span className="w-3 h-3 rounded-full bg-green-500 inline-block"></span> Submitted</div>
82+
<div className="flex items-center gap-1"><span className="w-3 h-3 rounded-full bg-yellow-400 inline-block"></span> Not Submitted</div>
83+
<div className="flex items-center gap-1"><span className="w-3 h-3 rounded-full bg-red-500 inline-block"></span> Missed</div>
84+
<div className="flex items-center gap-1"><span className="w-3 h-3 rounded-full bg-gray-300 inline-block"></span> Not Assigned</div>
85+
<div className="flex items-center gap-1"><span className="w-3 h-3 rounded-full border-2 border-blue-400 inline-block"></span> Today</div>
86+
</div>
87+
</div>
88+
</div>
89+
);
90+
};
91+
92+
export default DailyPulseCalendar;

0 commit comments

Comments
 (0)