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

54
spa/src/lib/api/goals.ts Normal file
View File

@@ -0,0 +1,54 @@
import { z } from "zod"
import { get, post, put, del } from "./client"
import { goalDtoSchema } from "./users"
export const goalsResponseSchema = z.object({
goals: z.array(goalDtoSchema),
})
export type GoalsResponse = z.infer<typeof goalsResponseSchema>
export type CreateGoalRequest = {
year: number
target_count: number
}
export type UpdateGoalRequest = {
target_count: number
}
export const userSettingsDtoSchema = z.object({
federate_goals: z.boolean(),
})
export type UserSettingsDto = z.infer<typeof userSettingsDtoSchema>
export type UpdateUserSettingsRequest = {
federate_goals: boolean
}
export function getGoals() {
return get<GoalsResponse>("/goals")
}
export function getUserGoals(userId: string) {
return get<GoalsResponse>(`/users/${userId}/goals`)
}
export function createGoal(data: CreateGoalRequest) {
return post<z.infer<typeof goalDtoSchema>>("/goals", data)
}
export function updateGoal(year: number, data: UpdateGoalRequest) {
return put<z.infer<typeof goalDtoSchema>>(`/goals/${year}`, data)
}
export function deleteGoal(year: number) {
return del(`/goals/${year}`)
}
export function getSettings() {
return get<UserSettingsDto>("/settings")
}
export function updateSettings(data: UpdateUserSettingsRequest) {
return put("/settings", data)
}

View File

@@ -63,6 +63,16 @@ export type MonthActivityDto = z.infer<typeof monthActivityDtoSchema>
const userDiaryResponseSchema = paginatedSchema(diaryEntryDtoSchema)
export const goalDtoSchema = z.object({
year: z.number(),
target_count: z.number(),
current_count: z.number(),
percentage: z.number(),
is_complete: z.boolean(),
goal_type: z.string(),
})
export type GoalDto = z.infer<typeof goalDtoSchema>
export const userProfileResponseSchema = z.object({
user_id: z.string().uuid(),
username: z.string(),
@@ -74,6 +84,7 @@ export const userProfileResponseSchema = z.object({
entries: userDiaryResponseSchema.optional(),
history: z.array(monthActivityDtoSchema).optional(),
trends: userTrendsDtoSchema.optional(),
goals: z.array(goalDtoSchema).optional(),
})
export type UserProfileResponse = z.infer<typeof userProfileResponseSchema>