Refactor insights handling and error encoding in presentation layer
- Updated insights analyzer tests to use Utc for date handling. - Added timezone support for insights retrieval, allowing insights to be bucketed by the user's local time. - Introduced `encode_domain_error` for better error handling in API responses. - Modified various handlers to utilize the new error encoding function. - Enhanced insights API to reject unknown time zones and return appropriate errors. - Updated frontend components to handle insights data with timezone awareness. - Improved localization for insights sample counts.
This commit is contained in:
@@ -8,6 +8,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
|
||||
import { Calendar } from "@/components/ui/calendar"
|
||||
import { StarRating } from "@/components/star-rating"
|
||||
import { WatchMediumPicker } from "@/components/watch-medium-picker"
|
||||
import { withPickedDay } from "@/lib/date"
|
||||
|
||||
type ReviewFormFieldsProps = {
|
||||
rating: number
|
||||
@@ -55,7 +56,7 @@ export function ReviewFormFields({
|
||||
mode="single"
|
||||
fixedWeeks
|
||||
selected={watchedAt}
|
||||
onSelect={(d) => d && onWatchedAtChange(d)}
|
||||
onSelect={(d) => d && onWatchedAtChange(withPickedDay(watchedAt, d))}
|
||||
disabled={(d) => d > new Date()}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Trash2 } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { posterUrl } from "@/lib/api/client"
|
||||
import { hapticMedium } from "@/lib/haptics"
|
||||
import { parseLocalDate, formatLocalDateTime } from "@/lib/date"
|
||||
import { formatWatchedAt, isDateOnly, parseWatchedAt } from "@/lib/date"
|
||||
import type { EditReviewRequest } from "@/features/diary"
|
||||
import type { MovieDto, ReviewDto } from "@/lib/api/common"
|
||||
|
||||
@@ -77,7 +77,7 @@ function LogMode({ open, onOpenChange }: { open: boolean; onOpenChange: (open: b
|
||||
manual_director: movie.director,
|
||||
rating,
|
||||
comment: comment || undefined,
|
||||
watched_at: watchedAt.toISOString().replace("Z", "").split(".")[0]!,
|
||||
watched_at: formatWatchedAt(watchedAt),
|
||||
watch_medium: watchMedium,
|
||||
},
|
||||
{
|
||||
@@ -145,7 +145,7 @@ function EditMode({
|
||||
const { t } = useTranslation()
|
||||
const [rating, setRating] = useState(review.rating)
|
||||
const [comment, setComment] = useState(review.comment ?? "")
|
||||
const [watchedAt, setWatchedAt] = useState<Date>(() => parseLocalDate(review.watched_at))
|
||||
const [watchedAt, setWatchedAt] = useState<Date>(() => parseWatchedAt(review.watched_at))
|
||||
const [dateChanged, setDateChanged] = useState(false)
|
||||
const [watchMedium, setWatchMedium] = useState<string | undefined>(review.watch_medium)
|
||||
const [confirmDelete, setConfirmDelete] = useState(false)
|
||||
@@ -164,7 +164,7 @@ function EditMode({
|
||||
if (rating !== review.rating) data.rating = rating
|
||||
const newComment = comment || null
|
||||
if (newComment !== (review.comment ?? null)) data.comment = newComment
|
||||
if (dateChanged) data.watched_at = formatLocalDateTime(watchedAt)
|
||||
if (dateChanged) data.watched_at = formatWatchedAt(watchedAt, isDateOnly(review.watched_at))
|
||||
if (watchMedium !== review.watch_medium) data.watch_medium = watchMedium ?? null
|
||||
|
||||
if (Object.keys(data).length === 0) {
|
||||
|
||||
@@ -47,15 +47,23 @@ export type InsightsResponse = {
|
||||
|
||||
export const insightsKeys = {
|
||||
all: ["insights"] as const,
|
||||
forZone: (tz: string) => [...insightsKeys.all, tz] as const,
|
||||
}
|
||||
|
||||
function getInsights() {
|
||||
return get<InsightsResponse>("/insights")
|
||||
// Months, weekdays and hours are bucketed server-side in this zone, so the
|
||||
// rhythm card's hours read as the viewer's local time.
|
||||
function browserTimeZone(): string {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
}
|
||||
|
||||
function getInsights(tz: string) {
|
||||
return get<InsightsResponse>("/insights", { tz })
|
||||
}
|
||||
|
||||
export function useInsights() {
|
||||
const tz = browserTimeZone()
|
||||
return useQuery({
|
||||
queryKey: insightsKeys.all,
|
||||
queryFn: getInsights,
|
||||
queryKey: insightsKeys.forZone(tz),
|
||||
queryFn: () => getInsights(tz),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -18,18 +18,34 @@ export function shortDate(dateStr: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function parseLocalDate(s: string): Date {
|
||||
const [datePart, timePart] = s.split("T")
|
||||
if (!datePart) return new Date()
|
||||
const [y, m, d] = datePart.split("-").map(Number)
|
||||
if (timePart) {
|
||||
const [h, min, sec] = timePart.split(":").map(Number)
|
||||
return new Date(y!, m! - 1, d!, h, min, sec)
|
||||
}
|
||||
return new Date(y!, m! - 1, d!)
|
||||
// `watched_at` is stored as naive UTC ("YYYY-MM-DDTHH:MM:SS", no zone).
|
||||
// Date-only imports carry no time and are stored at exactly 00:00:00; those
|
||||
// are calendar dates, not instants, and must not be shifted between zones.
|
||||
|
||||
export function isDateOnly(s: string): boolean {
|
||||
return /^\d{4}-\d{2}-\d{2}([T ]00:00(:00)?)?$/.test(s)
|
||||
}
|
||||
|
||||
export function formatLocalDateTime(d: Date): string {
|
||||
const pad = (n: number) => n.toString().padStart(2, "0")
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
export function parseWatchedAt(s: string): Date {
|
||||
const [y, m, d] = s.slice(0, 10).split("-").map(Number)
|
||||
if (isDateOnly(s)) return new Date(y!, m! - 1, d!)
|
||||
const parsed = new Date(`${s.replace(" ", "T")}Z`)
|
||||
return Number.isNaN(parsed.getTime()) ? new Date() : parsed
|
||||
}
|
||||
|
||||
export function formatWatchedAt(d: Date, dateOnly = false): string {
|
||||
const pad = (n: number) => n.toString().padStart(2, "0")
|
||||
if (dateOnly) {
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T00:00:00`
|
||||
}
|
||||
return d.toISOString().split(".")[0]!
|
||||
}
|
||||
|
||||
// Swap the calendar day of `current` for `picked` (the date picker yields local
|
||||
// midnight), keeping the time of day so the stored UTC instant stays on the
|
||||
// chosen day.
|
||||
export function withPickedDay(current: Date, picked: Date): Date {
|
||||
const next = new Date(current)
|
||||
next.setFullYear(picked.getFullYear(), picked.getMonth(), picked.getDate())
|
||||
return next
|
||||
}
|
||||
|
||||
@@ -275,7 +275,10 @@
|
||||
"weekends": "weekends",
|
||||
"yourBaseline": "your baseline",
|
||||
"rhythmBody": "You watch mostly <hl>{{start}}</hl>–<hl>{{end}}</hl> on <hl>{{days}}</hl>",
|
||||
"filmsSample": "{{count}} films"
|
||||
"filmsSample_one": "{{count}} film",
|
||||
"filmsSample_other": "{{count}} films",
|
||||
"watchesSample_one": "{{count}} watch",
|
||||
"watchesSample_other": "{{count}} watches"
|
||||
},
|
||||
"wrapup": {
|
||||
"title": "Year Wrap-Up",
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createFileRoute } from "@tanstack/react-router"
|
||||
import type { ReactNode } from "react"
|
||||
import { Trans, useTranslation } from "react-i18next"
|
||||
import {
|
||||
AlertTriangle,
|
||||
CalendarDays,
|
||||
Clapperboard,
|
||||
Clock,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
} from "lucide-react"
|
||||
import { BackButton } from "@/components/back-button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
@@ -54,7 +56,7 @@ function hourLabel(hour: number): string {
|
||||
|
||||
function InsightsPage() {
|
||||
const { t } = useTranslation()
|
||||
const { data, isPending } = useInsights()
|
||||
const { data, isPending, isError, refetch } = useInsights()
|
||||
|
||||
useDocumentTitle(t("insights.title"))
|
||||
if (isPending) return <InsightsSkeleton />
|
||||
@@ -72,7 +74,14 @@ function InsightsPage() {
|
||||
<p className="text-sm text-muted-foreground">{t("insights.subtitle")}</p>
|
||||
</div>
|
||||
|
||||
{insights.length === 0 ? (
|
||||
{isError ? (
|
||||
<div className="flex flex-col items-center">
|
||||
<EmptyState icon={AlertTriangle} title={t("errors.somethingWrong")} />
|
||||
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||
{t("common.tryAgain")}
|
||||
</Button>
|
||||
</div>
|
||||
) : insights.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Lightbulb}
|
||||
title={t("insights.emptyTitle")}
|
||||
@@ -161,7 +170,7 @@ function RatingLiftCard({ insight }: RatingLiftCardProps) {
|
||||
<Badge variant="outline">
|
||||
{t("insights.yourAverage")}: {insight.user_avg.toFixed(1)}★
|
||||
</Badge>
|
||||
<SampleBadge count={insight.sample_count} />
|
||||
<SampleBadge unit="films" count={insight.sample_count} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -193,7 +202,7 @@ function SeasonalityCard({ insight }: SeasonalityCardProps) {
|
||||
{insight.multiplier.toFixed(1)}× · {monthName(insight.month)}
|
||||
</Badge>
|
||||
<Badge variant="outline">{insight.genre}</Badge>
|
||||
<SampleBadge count={insight.sample_count} />
|
||||
<SampleBadge unit="watches" count={insight.sample_count} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -227,7 +236,7 @@ function MediumTimingCard({ insight }: MediumTimingCardProps) {
|
||||
<Badge variant="outline">
|
||||
{t("insights.yourBaseline")}: {Math.round(insight.baseline_share * 100)}%
|
||||
</Badge>
|
||||
<SampleBadge count={insight.sample_count} />
|
||||
<SampleBadge unit="watches" count={insight.sample_count} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -259,16 +268,19 @@ function RhythmCard({ insight }: RhythmCardProps) {
|
||||
{hourLabel(insight.peak_hour_start)}–{hourLabel(insight.peak_hour_end)}
|
||||
</Badge>
|
||||
<Badge variant="outline">{dayNames(insight.top_days).join(", ")}</Badge>
|
||||
<SampleBadge count={insight.sample_count} />
|
||||
<SampleBadge unit="watches" count={insight.sample_count} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function SampleBadge({ count }: { count: number }) {
|
||||
// Rating lift counts distinct films (first watches only); every other
|
||||
// category counts diary entries, rewatches included.
|
||||
function SampleBadge({ unit, count }: { unit: "films" | "watches"; count: number }) {
|
||||
const { t } = useTranslation()
|
||||
return <Badge variant="outline">{t("insights.filmsSample", { count })}</Badge>
|
||||
const key = unit === "films" ? "insights.filmsSample" : "insights.watchesSample"
|
||||
return <Badge variant="outline">{t(key, { count })}</Badge>
|
||||
}
|
||||
|
||||
function InsightsSkeleton() {
|
||||
|
||||
Reference in New Issue
Block a user