feat: goals — "watch N movies in YEAR" with progress bar

Domain: Goal entity, UserSettings (federation toggle), RemoteGoalEntry.
Ports: GoalRepository, UserSettingsRepository, RemoteGoalRepository.
Adapters: sqlite + postgres repos, migrations, AP content query extensions.
Application: CRUD use cases (create/update/delete/get/list), settings use cases.
API: 7 endpoints (/goals CRUD, /users/{id}/goals, /settings) with utoipa docs.
Federation: GoalObject (Note + goal discriminator), outbound broadcast with
per-user toggle, inbound GoalObjectHandler in CompositeObjectHandler.
SPA: API client + hooks, GoalCard (shadcn Card+Progress+DropdownMenu),
GoalSheet (Drawer), profile integration (editable own, read-only others),
federation toggle in settings (Switch).
Classic HTML: glassmorphic goal card on profile, Frutiger Aero styling.
Progress computed from existing reviews — backwards compatible.
This commit is contained in:
2026-06-08 22:37:52 +02:00
parent 213f9a2433
commit fff5f4af2f
67 changed files with 2747 additions and 28 deletions

View File

@@ -0,0 +1,70 @@
import { useTranslation } from "react-i18next"
import { Check, MoreHorizontal, Pencil, Target, Trash2 } from "lucide-react"
import { Card, CardContent } from "@/components/ui/card"
import { Progress } from "@/components/ui/progress"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Button } from "@/components/ui/button"
import type { GoalDto } from "@/lib/api/users"
type GoalCardProps = {
goal: GoalDto
editable?: boolean
onEdit?: () => void
onDelete?: () => void
}
export function GoalCard({ goal, editable, onEdit, onDelete }: GoalCardProps) {
const { t } = useTranslation()
return (
<Card>
<CardContent className="space-y-2 py-3 px-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Target className="size-4 text-muted-foreground" />
<span className="text-sm font-medium">
{t("goals.yearGoal", { year: goal.year })}
</span>
</div>
{editable && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="size-7 p-0">
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onEdit}>
<Pencil className="mr-2 size-3.5" />
{t("common.edit")}
</DropdownMenuItem>
<DropdownMenuItem onClick={onDelete} className="text-destructive">
<Trash2 className="mr-2 size-3.5" />
{t("common.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<Progress value={goal.percentage} className="h-2" />
<div className="flex justify-between text-xs text-muted-foreground">
<span>
{goal.current_count} / {goal.target_count} {t("goals.movies")}
</span>
<span>{Math.round(goal.percentage)}%</span>
</div>
{goal.is_complete && (
<p className="text-xs text-green-500 flex items-center gap-1">
<Check className="size-3" />
{t("goals.reached")}
</p>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,121 @@
import { useState } from "react"
import { useTranslation } from "react-i18next"
import { VisuallyHidden } from "radix-ui"
import { Drawer, DrawerContent, DrawerTitle } from "@/components/ui/drawer"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { useCreateGoal, useUpdateGoal } from "@/hooks/use-goals"
import { toast } from "sonner"
type GoalSheetProps = {
open: boolean
onOpenChange: (open: boolean) => void
editYear?: number
editTarget?: number
}
export function GoalSheet({
open,
onOpenChange,
editYear,
editTarget,
}: GoalSheetProps) {
const { t } = useTranslation()
const isEditing = editYear !== undefined
const currentYear = new Date().getFullYear()
const [year, setYear] = useState(editYear ?? currentYear)
const [target, setTarget] = useState(editTarget ?? 52)
const createMutation = useCreateGoal()
const updateMutation = useUpdateGoal()
function handleClose() {
onOpenChange(false)
if (!isEditing) {
setYear(currentYear)
setTarget(52)
}
}
function handleSubmit() {
if (target < 1) return
if (isEditing) {
updateMutation.mutate(
{ year, data: { target_count: target } },
{
onSuccess: () => {
toast.success(t("goals.updated"))
handleClose()
},
},
)
} else {
createMutation.mutate(
{ year, target_count: target },
{
onSuccess: () => {
toast.success(t("goals.created"))
handleClose()
},
},
)
}
}
const isPending = createMutation.isPending || updateMutation.isPending
return (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent className="px-4 pb-8">
<VisuallyHidden.Root>
<DrawerTitle>
{isEditing ? t("goals.editGoal") : t("goals.setGoal")}
</DrawerTitle>
</VisuallyHidden.Root>
<div className="mx-auto w-full max-w-sm space-y-6 pt-4">
<h2 className="text-lg font-semibold text-center">
{isEditing ? t("goals.editGoal") : t("goals.setGoal")}
</h2>
<div className="space-y-2">
<Label>{t("goals.year")}</Label>
<Input
type="number"
min={2020}
max={2100}
value={year}
onChange={(e) => setYear(Number(e.target.value))}
disabled={isEditing}
/>
</div>
<div className="space-y-2">
<Label>{t("goals.targetMovies")}</Label>
<Input
type="number"
min={1}
max={9999}
value={target}
onChange={(e) => setTarget(Number(e.target.value))}
/>
</div>
<Button
className="w-full"
onClick={handleSubmit}
disabled={isPending || target < 1}
>
{isPending
? t("common.saving")
: isEditing
? t("common.save")
: t("goals.setGoal")}
</Button>
</div>
</DrawerContent>
</Drawer>
)
}