Skip to content

Commit 5eb88be

Browse files
committed
Add page components for recording measurements
Created components for the measurements add page, including page, form, content, and actions. Integrated error handling, navigation, and form validations, and moved existing measurements page to a new location. Took 36 seconds
1 parent 60f3c4f commit 5eb88be

File tree

6 files changed

+464
-0
lines changed

6 files changed

+464
-0
lines changed
Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
1+
"use client"
2+
3+
import { useState } from "react"
4+
import { useRouter, useSearchParams } from "next/navigation"
5+
import { zodResolver } from "@hookform/resolvers/zod"
6+
import { useForm } from "react-hook-form"
7+
import * as z from "zod"
8+
import { format } from "date-fns"
9+
import { CalendarIcon } from "lucide-react"
10+
import { cn } from "@/lib/utils"
11+
import { variableCategories } from "@/app/dfda/lib/variableCategories"
12+
import { GlobalVariable } from "@/types/models/GlobalVariable"
13+
import { UserVariable } from "@/types/models/UserVariable"
14+
15+
import { Button } from "@/components/ui/button"
16+
import { Calendar } from "@/components/ui/calendar"
17+
import {
18+
Form,
19+
FormControl,
20+
FormField,
21+
FormItem,
22+
FormLabel,
23+
FormMessage,
24+
FormDescription,
25+
} from "@/components/ui/form"
26+
import {
27+
Select,
28+
SelectContent,
29+
SelectItem,
30+
SelectTrigger,
31+
SelectValue,
32+
} from "@/components/ui/select"
33+
import { Input } from "@/components/ui/input"
34+
import {
35+
Popover,
36+
PopoverContent,
37+
PopoverTrigger,
38+
} from "@/components/ui/popover"
39+
import { Textarea } from "@/components/ui/textarea"
40+
import { toast } from "@/components/ui/use-toast"
41+
import { createMeasurement } from "./measurementActions"
42+
import { Valence, ratingButtons } from "@/lib/constants/ratings"
43+
44+
const measurementFormSchema = z.object({
45+
variableName: z.string().min(1, "Variable name is required"),
46+
variableCategoryName: z.string().min(1, "Category is required"),
47+
value: z.number().or(z.string()).optional(),
48+
unitAbbreviatedName: z.string().min(1, "Unit is required"),
49+
note: z.string().optional(),
50+
startAt: z.date(),
51+
})
52+
53+
type MeasurementFormValues = z.infer<typeof measurementFormSchema>
54+
55+
interface MeasurementFormProps {
56+
variable?: GlobalVariable | UserVariable
57+
}
58+
59+
export function MeasurementForm({ variable }: MeasurementFormProps) {
60+
const router = useRouter()
61+
const searchParams = useSearchParams()
62+
const [loading, setLoading] = useState(false)
63+
64+
const defaultValues: Partial<MeasurementFormValues> = {
65+
startAt: new Date(),
66+
...(variable && {
67+
variableName: variable.name,
68+
variableCategoryName: variable.variableCategoryName,
69+
unitAbbreviatedName: variable.unitAbbreviatedName,
70+
value: variable.mostCommonValue,
71+
})
72+
}
73+
74+
const form = useForm<MeasurementFormValues>({
75+
resolver: zodResolver(measurementFormSchema),
76+
defaultValues,
77+
})
78+
79+
const handleFaceButtonClick = (numericValue: number) => {
80+
form.setValue("value", numericValue, { shouldValidate: true })
81+
}
82+
83+
async function onSubmit(data: MeasurementFormValues) {
84+
try {
85+
setLoading(true)
86+
87+
const result = await createMeasurement({
88+
...data,
89+
variableId: variable?.variableId,
90+
})
91+
92+
if (!result.success) throw new Error(result.error)
93+
94+
toast({
95+
description: `Recorded ${data.value} ${data.unitAbbreviatedName} for ${data.variableName} on ${format(data.startAt, "PPP")}`,
96+
})
97+
98+
router.push("/dfda/safe/measurements")
99+
router.refresh()
100+
} catch (error) {
101+
toast({
102+
title: "Error",
103+
description: error instanceof Error ? error.message : "Failed to save measurement",
104+
variant: "destructive",
105+
})
106+
} finally {
107+
setLoading(false)
108+
}
109+
}
110+
111+
// Determine if we should show rating buttons
112+
const showRatingButtons = variable?.unitAbbreviatedName === "/5" && variable?.valence
113+
const buttons = showRatingButtons ? ratingButtons[variable.valence as Valence] : null
114+
115+
// Check if we have an existing variable
116+
const isExistingVariable = Boolean(variable?.variableId)
117+
118+
return (
119+
<Form {...form}>
120+
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
121+
{!isExistingVariable && (
122+
<>
123+
<FormField
124+
control={form.control}
125+
name="variableName"
126+
render={({ field }) => (
127+
<FormItem>
128+
<FormLabel className="text-black font-bold">Variable Name</FormLabel>
129+
<FormControl>
130+
<Input
131+
placeholder="Enter variable name"
132+
{...field}
133+
className="border-2 border-black bg-white text-black placeholder:text-gray-500 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)]"
134+
/>
135+
</FormControl>
136+
<FormMessage className="text-red-500" />
137+
</FormItem>
138+
)}
139+
/>
140+
141+
<FormField
142+
control={form.control}
143+
name="variableCategoryName"
144+
render={({ field }) => (
145+
<FormItem>
146+
<FormLabel className="text-black font-bold">Category</FormLabel>
147+
<Select onValueChange={field.onChange} defaultValue={field.value}>
148+
<FormControl>
149+
<SelectTrigger className="border-2 border-black bg-white text-black shadow-[2px_2px_0px_0px_rgba(0,0,0,1)]">
150+
<SelectValue placeholder="Select category" />
151+
</SelectTrigger>
152+
</FormControl>
153+
<SelectContent className="border-2 border-black bg-white">
154+
{variableCategories.map((category) => (
155+
<SelectItem
156+
key={category.name}
157+
value={category.name}
158+
className="hover:bg-gray-100"
159+
>
160+
{category.name}
161+
</SelectItem>
162+
))}
163+
</SelectContent>
164+
</Select>
165+
<FormMessage className="text-red-500" />
166+
</FormItem>
167+
)}
168+
/>
169+
</>
170+
)}
171+
172+
<FormField
173+
control={form.control}
174+
name="value"
175+
render={({ field }) => (
176+
<FormItem>
177+
<FormLabel className="text-black font-bold">Value</FormLabel>
178+
{buttons ? (
179+
<div className="flex w-full justify-around items-center">
180+
{buttons.map((option) => (
181+
<img
182+
key={option.numericValue}
183+
src={option.src}
184+
title={option.title}
185+
className={`cursor-pointer ${
186+
form.watch("value") === option.numericValue
187+
? "brightness-110 scale-110 drop-shadow-[0_0_8px_rgba(59,130,246,0.5)]"
188+
: "brightness-90 hover:brightness-100 scale-75 hover:scale-90"
189+
} w-auto max-w-[20%] transition-all duration-200 rounded-full`}
190+
onClick={() => handleFaceButtonClick(option.numericValue)}
191+
alt={`Rating ${option.numericValue}`}
192+
/>
193+
))}
194+
</div>
195+
) : (
196+
<FormControl>
197+
<div className="flex items-center gap-2">
198+
<Input
199+
type="number"
200+
placeholder="Enter value"
201+
min={variable?.minimumAllowedValue}
202+
max={variable?.maximumAllowedValue}
203+
{...field}
204+
onChange={e => field.onChange(e.target.valueAsNumber)}
205+
className="border-2 border-black bg-white text-black placeholder:text-gray-500 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)]"
206+
/>
207+
<span className="text-sm font-bold text-black">
208+
{variable?.unitAbbreviatedName}
209+
</span>
210+
</div>
211+
</FormControl>
212+
)}
213+
<FormMessage className="text-red-500" />
214+
</FormItem>
215+
)}
216+
/>
217+
218+
<FormField
219+
control={form.control}
220+
name="startAt"
221+
render={({ field }) => (
222+
<FormItem className="flex flex-col">
223+
<FormLabel className="text-black font-bold">Date & Time</FormLabel>
224+
<Popover>
225+
<PopoverTrigger asChild>
226+
<FormControl>
227+
<Button
228+
variant={"outline"}
229+
className={cn(
230+
"w-full pl-3 text-left font-normal border-2 border-black bg-white text-black shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] hover:shadow-none hover:translate-x-[2px] hover:translate-y-[2px] transition-all",
231+
!field.value && "text-gray-500"
232+
)}
233+
>
234+
{field.value ? (
235+
format(field.value, "PPP HH:mm")
236+
) : (
237+
<span>Pick a date</span>
238+
)}
239+
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
240+
</Button>
241+
</FormControl>
242+
</PopoverTrigger>
243+
<PopoverContent className="border-2 border-black bg-white p-0" align="start">
244+
<Calendar
245+
mode="single"
246+
selected={field.value}
247+
onSelect={field.onChange}
248+
disabled={(date) =>
249+
date > new Date() || date < new Date("1900-01-01")
250+
}
251+
initialFocus
252+
className="bg-white"
253+
/>
254+
<div className="p-3 border-t-2 border-black">
255+
<Input
256+
type="time"
257+
value={format(field.value || new Date(), "HH:mm")}
258+
onChange={(e) => {
259+
const [hours, minutes] = e.target.value.split(":")
260+
const newDate = new Date(field.value || new Date())
261+
newDate.setHours(parseInt(hours))
262+
newDate.setMinutes(parseInt(minutes))
263+
field.onChange(newDate)
264+
}}
265+
className="border-2 border-black bg-white text-black shadow-[2px_2px_0px_0px_rgba(0,0,0,1)]"
266+
/>
267+
</div>
268+
</PopoverContent>
269+
</Popover>
270+
<FormDescription className="text-gray-600">When this measurement was taken</FormDescription>
271+
<FormMessage className="text-red-500" />
272+
</FormItem>
273+
)}
274+
/>
275+
276+
<FormField
277+
control={form.control}
278+
name="note"
279+
render={({ field }) => (
280+
<FormItem>
281+
<FormLabel className="text-black font-bold">Note</FormLabel>
282+
<FormControl>
283+
<Textarea
284+
placeholder="Add any additional notes..."
285+
className="resize-none border-2 border-black bg-white text-black placeholder:text-gray-500 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)]"
286+
{...field}
287+
/>
288+
</FormControl>
289+
<FormMessage className="text-red-500" />
290+
</FormItem>
291+
)}
292+
/>
293+
294+
<div className="flex gap-4">
295+
<Button
296+
type="button"
297+
variant="outline"
298+
onClick={() => router.back()}
299+
className="border-2 border-black bg-white text-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] hover:shadow-none hover:translate-x-[4px] hover:translate-y-[4px] transition-all"
300+
>
301+
Cancel
302+
</Button>
303+
<Button
304+
type="submit"
305+
disabled={loading}
306+
className="border-2 border-black bg-blue-500 text-white shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] hover:shadow-none hover:translate-x-[4px] hover:translate-y-[4px] transition-all disabled:opacity-50"
307+
>
308+
{loading ? "Saving..." : "Record Measurement"}
309+
</Button>
310+
</div>
311+
</form>
312+
</Form>
313+
)
314+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"use server"
2+
3+
import { z } from "zod"
4+
import { revalidatePath } from "next/cache"
5+
import { postMeasurements } from "@/lib/dfda"
6+
import { getUserIdServer } from "@/lib/api/getUserIdServer"
7+
8+
const measurementSchema = z.object({
9+
variableName: z.string(),
10+
variableCategoryName: z.string(),
11+
value: z.number().or(z.string()).optional(),
12+
unitAbbreviatedName: z.string(),
13+
note: z.string().optional(),
14+
startAt: z.date(),
15+
variableId: z.number().optional(),
16+
})
17+
18+
export async function createMeasurement(data: z.infer<typeof measurementSchema>) {
19+
try {
20+
const userId = await getUserIdServer()
21+
if (!userId) {
22+
throw new Error("Not authenticated")
23+
}
24+
25+
const validated = measurementSchema.parse(data)
26+
27+
// Format the measurement for the DFDA API
28+
const measurement = {
29+
variableId: validated.variableId,
30+
sourceName: "Wishonia",
31+
unitAbbreviatedName: validated.unitAbbreviatedName,
32+
value: validated.value,
33+
note: validated.note,
34+
startAt: validated.startAt.toISOString(),
35+
variableName: validated.variableName,
36+
variableCategoryName: validated.variableCategoryName,
37+
}
38+
39+
await postMeasurements(measurement, userId)
40+
41+
revalidatePath("/measurements")
42+
return { success: true }
43+
} catch (error) {
44+
console.error("Failed to save measurement:", error)
45+
if (error instanceof Error) {
46+
return { success: false, error: error.message }
47+
}
48+
return { success: false, error: "Failed to save measurement" }
49+
}
50+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export default function NotFound() {
2+
return (
3+
<div className="container mx-auto py-6">
4+
<h2 className="text-2xl font-bold">Variable Not Found</h2>
5+
<p className="mt-4">Could not find the requested variable.</p>
6+
</div>
7+
)
8+
}

0 commit comments

Comments
 (0)