diff --git a/apps/mobile/app/(app)/[workspace]/more/projects.tsx b/apps/mobile/app/(app)/[workspace]/more/projects.tsx index a78b8ae94..8622b5ccf 100644 --- a/apps/mobile/app/(app)/[workspace]/more/projects.tsx +++ b/apps/mobile/app/(app)/[workspace]/more/projects.tsx @@ -1,17 +1,147 @@ -import { View } from "react-native"; -import { Text } from "@/components/ui/text"; - /** - * Projects browse page (placeholder). Read-only list of workspace projects, - * filled in a later phase. Title comes from Stack.Screen options in - * `[workspace]/_layout.tsx`. + * Projects browse page. Flat FlatList over the workspace's projects. + * + * Title and `+` button live in the native iOS Stack header (declared via + * Stack.Screen options in parent `_layout.tsx`, overridden here to add + * `headerRight`). Rendering an in-body title row on top of the native bar + * would stack two "Projects" labels vertically. + * + * Sort: client-side by `updated_at` desc — most recently touched at top. + * Mirrors web's default list ordering. WS `project:*` events keep the cache + * fresh via the listing-level realtime hook (`useProjectsRealtime` in + * `_layout.tsx`), so pull-to-refresh is rarely needed but kept for the + * cellular-edge case where a WS reconnect missed events. */ +import { useCallback, useMemo } from "react"; +import { + ActivityIndicator, + FlatList, + Pressable, + RefreshControl, + View, +} from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { useQuery } from "@tanstack/react-query"; +import { Stack, router } from "expo-router"; +import Svg, { Line } from "react-native-svg"; +import { Text } from "@/components/ui/text"; +import { Button } from "@/components/ui/button"; +import { ProjectRow } from "@/components/project/project-row"; +import { projectListOptions } from "@/data/queries/projects"; +import { useWorkspaceStore } from "@/data/workspace-store"; + export default function ProjectsPage() { + const wsId = useWorkspaceStore((s) => s.currentWorkspaceId); + const wsSlug = useWorkspaceStore((s) => s.currentWorkspaceSlug); + + const { data, isLoading, error, refetch, isRefetching } = useQuery( + projectListOptions(wsId), + ); + + const sorted = useMemo(() => { + if (!data) return []; + return [...data].sort( + (a, b) => + new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(), + ); + }, [data]); + + const goCreate = useCallback(() => { + if (wsSlug) router.push(`/${wsSlug}/project/new`); + }, [wsSlug]); + + const headerRight = useCallback(() => { + return ; + }, [goCreate]); + return ( - - - Projects coming soon. + + + + {isLoading ? ( + + + + ) : error ? ( + + + Failed to load projects:{" "} + {error instanceof Error ? error.message : "unknown error"} + + + + ) : sorted.length === 0 ? ( + + ) : ( + item.id} + ItemSeparatorComponent={() => ( + + )} + renderItem={({ item }) => ( + { + if (wsSlug) router.push(`/${wsSlug}/project/${item.id}`); + }} + /> + )} + refreshControl={ + + } + contentContainerClassName="pb-6" + /> + )} + + ); +} + +function PlusButton({ onPress }: { onPress: () => void }) { + return ( + + + + + + + ); +} + +function EmptyState({ onCreate }: { onCreate: () => void }) { + return ( + + + No projects yet + + Group related issues into a project to track progress and assign a + lead. + + ); } diff --git a/apps/mobile/app/(app)/[workspace]/project/[id].tsx b/apps/mobile/app/(app)/[workspace]/project/[id].tsx new file mode 100644 index 000000000..b22435c2e --- /dev/null +++ b/apps/mobile/app/(app)/[workspace]/project/[id].tsx @@ -0,0 +1,273 @@ +/** + * Project detail screen. Single column, scrolling: + * + * Header card (icon + title + description, tap → edit) + * Properties section (Status / Priority / Lead — tap chip → picker) + * Resources section (read-only by default, "Add" button → resource form) + * Related issues (Open / Done bucketed list) + * + * Per-record realtime: `useProjectRealtime(id, onDeleted=back)` subscribes + * to `project:updated` (full replace) and `project:deleted` (pop back). + * + * Right-top "…" menu (ActionSheetIOS) → Edit / Delete. Delete asks for + * confirmation via `Alert.alert` per iOS HIG (destructive actions need + * a second tap). + */ +import { useCallback, useState } from "react"; +import { + ActionSheetIOS, + ActivityIndicator, + Alert, + Linking, + Platform, + Pressable, + RefreshControl, + ScrollView, + View, +} from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { Stack, router, useLocalSearchParams } from "expo-router"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { Ionicons } from "@expo/vector-icons"; +import type { + CreateProjectResourceRequest, + ProjectPriority, + ProjectStatus, +} from "@multica/core/types"; +import { Text } from "@/components/ui/text"; +import { Button } from "@/components/ui/button"; +import { ProjectHeaderCard } from "@/components/project/project-header-card"; +import { ProjectPropertiesSection } from "@/components/project/project-properties-section"; +import { ProjectRelatedIssues } from "@/components/project/project-related-issues"; +import { ProjectResourcesSection } from "@/components/project/project-resources-section"; +import { ProjectStatusPickerSheet } from "@/components/project/pickers/project-status-picker-sheet"; +import { ProjectPriorityPickerSheet } from "@/components/project/pickers/project-priority-picker-sheet"; +import { + ProjectLeadPickerSheet, + type LeadValue, +} from "@/components/project/pickers/project-lead-picker-sheet"; +import { AddResourceSheet } from "@/components/project/add-resource-sheet"; +import { + projectDetailOptions, + projectResourcesOptions, +} from "@/data/queries/projects"; +import { issueKeys } from "@/data/queries/issue-keys"; +import { + useCreateProjectResource, + useDeleteProject, + useUpdateProject, +} from "@/data/mutations/projects"; +import { useProjectRealtime } from "@/data/realtime/use-project-realtime"; +import { useWorkspaceStore } from "@/data/workspace-store"; + +export default function ProjectDetail() { + const { id } = useLocalSearchParams<{ id: string }>(); + const wsId = useWorkspaceStore((s) => s.currentWorkspaceId); + const wsSlug = useWorkspaceStore((s) => s.currentWorkspaceSlug); + const qc = useQueryClient(); + + const detail = useQuery(projectDetailOptions(wsId, id)); + const updateProject = useUpdateProject(id); + const deleteProject = useDeleteProject(id); + const createResource = useCreateProjectResource(id); + + const [statusOpen, setStatusOpen] = useState(false); + const [priorityOpen, setPriorityOpen] = useState(false); + const [leadOpen, setLeadOpen] = useState(false); + const [resourceOpen, setResourceOpen] = useState(false); + + // Per-record realtime — when another client deletes the project we're + // viewing, pop back so the user isn't stranded on a 404. + useProjectRealtime(id, () => router.back()); + + const onRefresh = useCallback(async () => { + await Promise.all([ + detail.refetch(), + qc.invalidateQueries({ queryKey: projectResourcesOptions(wsId, id).queryKey }), + qc.invalidateQueries({ + queryKey: [...issueKeys.list(wsId), "byProject", id], + }), + ]); + }, [detail, qc, wsId, id]); + + const project = detail.data; + + // EMPTY_PROJECT carries an empty id — parseWithFallback returned the + // fallback because the response shape drifted. Treat as "not found". + const projectMissing = !project || project.id === ""; + + const onPressMore = () => { + if (!project) return; + const wsUrl = process.env.EXPO_PUBLIC_WEB_URL; + const options = [ + "Cancel", + "Edit details", + ...(wsUrl ? ["Open on web"] : []), + "Delete", + ]; + const destructiveIndex = options.length - 1; + ActionSheetIOS.showActionSheetWithOptions( + { + options, + cancelButtonIndex: 0, + destructiveButtonIndex: destructiveIndex, + }, + (i) => { + if (i === 1) { + if (wsSlug) router.push(`/${wsSlug}/project/${id}/edit`); + return; + } + if (wsUrl && i === 2) { + Linking.openURL(`${wsUrl}/${wsSlug}/projects/${id}`); + return; + } + if (i === destructiveIndex) { + onDelete(); + } + }, + ); + }; + + const onDelete = () => { + Alert.alert( + "Delete project?", + "This cannot be undone. Issues in this project will become unassigned from any project.", + [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + style: "destructive", + onPress: () => { + deleteProject.mutate(undefined, { + onSuccess: () => router.back(), + }); + }, + }, + ], + ); + }; + + const onAddResource = (body: CreateProjectResourceRequest) => { + createResource.mutate(body, { + onSuccess: () => setResourceOpen(false), + onError: (err) => { + Alert.alert( + "Failed to attach resource", + err instanceof Error ? err.message : "Unknown error", + ); + }, + }); + }; + + return ( + + ( + + + + ) + : undefined, + }} + /> + {detail.isLoading ? ( + + + + ) : detail.error || projectMissing ? ( + + + Failed to load project:{" "} + {detail.error instanceof Error + ? detail.error.message + : "not found"} + + + + ) : ( + + } + keyboardDismissMode="on-drag" + > + { + if (wsSlug) router.push(`/${wsSlug}/project/${id}/edit`); + }} + /> + setStatusOpen(true)} + onPressPriority={() => setPriorityOpen(true)} + onPressLead={() => setLeadOpen(true)} + /> + setResourceOpen(true)} + /> + + + + )} + + {project ? ( + <> + + updateProject.mutate({ status: next }) + } + onClose={() => setStatusOpen(false)} + /> + + updateProject.mutate({ priority: next }) + } + onClose={() => setPriorityOpen(false)} + /> + + updateProject.mutate( + next + ? { lead_type: next.type, lead_id: next.id } + : { lead_type: null, lead_id: null }, + ) + } + onClose={() => setLeadOpen(false)} + /> + setResourceOpen(false)} + submitting={createResource.isPending} + /> + + ) : null} + + ); +} diff --git a/apps/mobile/app/(app)/[workspace]/project/[id]/edit.tsx b/apps/mobile/app/(app)/[workspace]/project/[id]/edit.tsx new file mode 100644 index 000000000..21ce1f7ad --- /dev/null +++ b/apps/mobile/app/(app)/[workspace]/project/[id]/edit.tsx @@ -0,0 +1,203 @@ +/** + * Edit project title / description / icon. Modal presentation, configured + * in `[workspace]/_layout.tsx`. Save button in the header runs an + * optimistic `useUpdateProject`; the modal dismisses on success. + * + * Cancel/dismiss flow: header Cancel + iOS drag-down gesture both check + * dirty state and pop an Alert if there are unsaved edits. + */ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + Alert, + KeyboardAvoidingView, + Platform, + Pressable, + ScrollView, + TextInput, + View, +} from "react-native"; +import { Stack, router, useLocalSearchParams } from "expo-router"; +import { useQuery } from "@tanstack/react-query"; +import { Text } from "@/components/ui/text"; +import { + MIN_BODY_INPUT_HEIGHT_PX, + MOBILE_PLACEHOLDER_COLOR, +} from "@/components/ui/input-tokens"; +import { projectDetailOptions } from "@/data/queries/projects"; +import { useUpdateProject } from "@/data/mutations/projects"; +import { useWorkspaceStore } from "@/data/workspace-store"; + +export default function EditProject() { + const { id } = useLocalSearchParams<{ id: string }>(); + const wsId = useWorkspaceStore((s) => s.currentWorkspaceId); + const detail = useQuery(projectDetailOptions(wsId, id)); + const update = useUpdateProject(id); + + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [icon, setIcon] = useState(""); + const [seeded, setSeeded] = useState(false); + + // Seed local state once detail lands. Effect (not setState-in-render) + // so we don't accidentally retrigger on every parent re-render — the + // `seeded` guard makes it idempotent. + useEffect(() => { + if (!detail.data || seeded) return; + setTitle(detail.data.title); + setDescription(detail.data.description ?? ""); + setIcon(detail.data.icon ?? ""); + setSeeded(true); + }, [detail.data, seeded]); + + const dirty = useMemo(() => { + if (!detail.data) return false; + return ( + title.trim() !== detail.data.title || + description.trim() !== (detail.data.description ?? "") || + icon.trim() !== (detail.data.icon ?? "") + ); + }, [detail.data, title, description, icon]); + + const canSave = + seeded && title.trim().length > 0 && dirty && !update.isPending; + + const onCancel = useCallback(() => { + if (!dirty) { + router.back(); + return; + } + Alert.alert( + "Discard changes?", + "Your edits to this project will be lost.", + [ + { text: "Keep editing", style: "cancel" }, + { + text: "Discard", + style: "destructive", + onPress: () => router.back(), + }, + ], + ); + }, [dirty]); + + const onSave = useCallback(() => { + if (!canSave) return; + const patch = { + title: title.trim(), + description: description.trim() || null, + icon: icon.trim() || null, + }; + update.mutate(patch, { + onSuccess: () => router.back(), + onError: (err) => { + Alert.alert( + "Failed to save", + err instanceof Error ? err.message : "Unknown error", + ); + }, + }); + }, [canSave, title, description, icon, update]); + + const headerLeft = useCallback(() => { + return ( + + Cancel + + ); + }, [onCancel]); + + const headerRight = useCallback(() => { + return ( + + + {update.isPending ? "Saving…" : "Save"} + + + ); + }, [canSave, onSave, update.isPending]); + + return ( + <> + + + + {!detail.data ? ( + Loading… + ) : ( + <> + + { + // Cap at two characters — emoji are usually 1-2 UTF-16 + // code units. Prevents the user typing a full sentence + // by accident. + setIcon(v.slice(0, 4)); + }} + placeholder="📦" + placeholderTextColor={MOBILE_PLACEHOLDER_COLOR} + className="text-2xl text-foreground bg-secondary/50 rounded-md px-3 py-2 self-start min-w-[60px] text-center" + maxLength={4} + /> + + + + + + + + + + + )} + + + + ); +} + +function Field({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( + + + {label} + + {children} + + ); +} + diff --git a/apps/mobile/app/(app)/[workspace]/project/new.tsx b/apps/mobile/app/(app)/[workspace]/project/new.tsx new file mode 100644 index 000000000..d185c0f16 --- /dev/null +++ b/apps/mobile/app/(app)/[workspace]/project/new.tsx @@ -0,0 +1,248 @@ +/** + * New project modal. Mirrors `new-issue.tsx` shape — vertical form, header + * Cancel / Create buttons. Title is required; everything else has a default + * (status=planned, priority=none, no lead, no description, no icon). + * + * Lead is intentionally NOT exposed in the create form. Web does the same: + * lead assignment is a follow-up action because most users create the + * project from a "I need to track this stream of work" intent and figure + * out who's leading it later. The picker lives on the detail screen. + * + * On success: dismiss modal → navigate to the new project's detail page so + * the user can immediately add a lead / attach issues / configure properties. + */ +import { useCallback, useState } from "react"; +import { + Alert, + InteractionManager, + KeyboardAvoidingView, + Platform, + Pressable, + ScrollView, + TextInput, + View, +} from "react-native"; +import { Stack, router } from "expo-router"; +import type { ProjectPriority, ProjectStatus } from "@multica/core/types"; +import { Text } from "@/components/ui/text"; +import { + MIN_BODY_INPUT_HEIGHT_PX, + MOBILE_PLACEHOLDER_COLOR, +} from "@/components/ui/input-tokens"; +import { ProjectStatusIcon } from "@/components/ui/project-status-icon"; +import { ProjectPriorityIcon } from "@/components/ui/project-priority-icon"; +import { ProjectStatusPickerSheet } from "@/components/project/pickers/project-status-picker-sheet"; +import { ProjectPriorityPickerSheet } from "@/components/project/pickers/project-priority-picker-sheet"; +import { + projectPriorityLabel, + projectStatusLabel, +} from "@/lib/project-status"; +import { useCreateProject } from "@/data/mutations/projects"; +import { useWorkspaceStore } from "@/data/workspace-store"; + +export default function NewProject() { + const wsSlug = useWorkspaceStore((s) => s.currentWorkspaceSlug); + const create = useCreateProject(); + + const [title, setTitle] = useState(""); + const [icon, setIcon] = useState(""); + const [description, setDescription] = useState(""); + const [status, setStatus] = useState("planned"); + const [priority, setPriority] = useState("none"); + + const [statusOpen, setStatusOpen] = useState(false); + const [priorityOpen, setPriorityOpen] = useState(false); + + const dirty = + title.length > 0 || + icon.length > 0 || + description.length > 0 || + status !== "planned" || + priority !== "none"; + + const canCreate = title.trim().length > 0 && !create.isPending; + + const onCancel = useCallback(() => { + if (!dirty) { + router.back(); + return; + } + Alert.alert( + "Discard project?", + "Your draft will be lost.", + [ + { text: "Keep editing", style: "cancel" }, + { + text: "Discard", + style: "destructive", + onPress: () => router.back(), + }, + ], + ); + }, [dirty]); + + const onCreate = useCallback(() => { + if (!canCreate) return; + create.mutate( + { + title: title.trim(), + description: description.trim() || undefined, + icon: icon.trim() || undefined, + status, + priority, + }, + { + onSuccess: (project) => { + router.back(); + // Wait for the modal dismiss animation to finish before pushing + // the detail screen. `InteractionManager` resolves once iOS + // says all in-flight animations / interactions are done — more + // robust than a hard-coded `setTimeout(150)` if iOS timing + // changes or the device is under load. + InteractionManager.runAfterInteractions(() => { + if (wsSlug) router.push(`/${wsSlug}/project/${project.id}`); + }); + }, + onError: (err) => { + Alert.alert( + "Failed to create project", + err instanceof Error ? err.message : "Unknown error", + ); + }, + }, + ); + }, [canCreate, create, title, description, icon, status, priority, wsSlug]); + + const headerLeft = useCallback(() => { + return ( + + Cancel + + ); + }, [onCancel]); + + const headerRight = useCallback(() => { + return ( + + + {create.isPending ? "Creating…" : "Create"} + + + ); + }, [canCreate, onCreate, create.isPending]); + + return ( + <> + + + + + setIcon(v.slice(0, 4))} + placeholder="📦" + placeholderTextColor={MOBILE_PLACEHOLDER_COLOR} + className="text-2xl text-foreground bg-secondary/50 rounded-md px-3 py-2 self-start min-w-[60px] text-center" + maxLength={4} + /> + + + + + + + + + + + + + + setStatusOpen(true)} + className="flex-row items-center gap-2 bg-secondary/50 rounded-md px-3 py-2.5" + > + + + {projectStatusLabel(status)} + + + + + + + setPriorityOpen(true)} + className="flex-row items-center gap-2 bg-secondary/50 rounded-md px-3 py-2.5" + > + + + {projectPriorityLabel(priority)} + + + + + + + + + setStatusOpen(false)} + /> + setPriorityOpen(false)} + /> + + ); +} + +function Field({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( + + + {label} + + {children} + + ); +} diff --git a/apps/mobile/components/project/add-resource-sheet.tsx b/apps/mobile/components/project/add-resource-sheet.tsx new file mode 100644 index 000000000..a9d217d11 --- /dev/null +++ b/apps/mobile/components/project/add-resource-sheet.tsx @@ -0,0 +1,131 @@ +/** + * Attach a GitHub repo to a project. v1 only supports `github_repo` resource + * type — server will accept the JSON ref `{ url }`. Optional label so the + * row in the list reads as something the user picked rather than the raw URL. + * + * Minimal client-side validation: the URL must look like + * `https://github.com/owner/repo`. Anything else surfaces a Submit error + * from the server (real validation lives there). + * + * Modal shell mirrors other picker sheets — Pressable backdrop, centered + * card, tap-outside-to-dismiss. Phone keyboards push the card up + * naturally; no need for KeyboardAvoidingView at the modal scope. + */ +import { useState } from "react"; +import { Modal, Pressable, TextInput, View } from "react-native"; +import type { CreateProjectResourceRequest } from "@multica/core/types"; +import { Text } from "@/components/ui/text"; +import { Button } from "@/components/ui/button"; +import { MOBILE_PLACEHOLDER_COLOR } from "@/components/ui/input-tokens"; + +interface Props { + visible: boolean; + onSubmit: (body: CreateProjectResourceRequest) => void; + onClose: () => void; + submitting?: boolean; +} + +// Loose prefix match — accepts `owner/repo`, `owner/repo.git`, +// `owner/repo/tree/main`, etc. Server is the canonical validator +// (validateAndNormalizeResourceRef on the Go side); we only gate the +// Attach button on "this looks like a GitHub repo URL at all". +const GITHUB_PATTERN = /^https:\/\/github\.com\/[\w.-]+\/[\w.-]+(\/|$)/i; + +export function AddResourceSheet({ + visible, + onSubmit, + onClose, + submitting, +}: Props) { + const [url, setUrl] = useState(""); + const [label, setLabel] = useState(""); + + const reset = () => { + setUrl(""); + setLabel(""); + }; + + const close = () => { + reset(); + onClose(); + }; + + const valid = GITHUB_PATTERN.test(url.trim()); + + const submit = () => { + if (!valid || submitting) return; + onSubmit({ + resource_type: "github_repo", + resource_ref: { url: url.trim() }, + label: label.trim() || undefined, + }); + reset(); + }; + + return ( + + + + {}} className="w-full max-w-sm"> + + + Attach GitHub repository + + + + Repository URL + + + + + + Label (optional) + + + + + + + + + + + + + ); +} diff --git a/apps/mobile/components/project/pickers/project-lead-picker-sheet.tsx b/apps/mobile/components/project/pickers/project-lead-picker-sheet.tsx new file mode 100644 index 000000000..f2ec9fb53 --- /dev/null +++ b/apps/mobile/components/project/pickers/project-lead-picker-sheet.tsx @@ -0,0 +1,230 @@ +/** + * Project lead picker. Single-select over members + agents, with a top + * "Unassigned" row to clear. Search bar filters by name. + * + * Shell mirrors issue/pickers/assignee-picker-sheet.tsx — a bottom-half + * modal with a search input on top, sectioned list below (Members on top, + * Agents below). Tap a row to apply (single-step). + */ +import { useMemo, useState } from "react"; +import { + ActivityIndicator, + Modal, + Pressable, + SectionList, + TextInput, + View, +} from "react-native"; +import { useQuery } from "@tanstack/react-query"; +import { Ionicons } from "@expo/vector-icons"; +import type { Agent, MemberWithUser } from "@multica/core/types"; +import { Text } from "@/components/ui/text"; +import { ActorAvatar } from "@/components/ui/actor-avatar"; +import { MOBILE_PLACEHOLDER_COLOR } from "@/components/ui/input-tokens"; +import { agentListOptions } from "@/data/queries/agents"; +import { memberListOptions } from "@/data/queries/members"; +import { useWorkspaceStore } from "@/data/workspace-store"; +import { cn } from "@/lib/utils"; + +export interface LeadValue { + type: "member" | "agent"; + id: string; +} + +interface Props { + visible: boolean; + value: LeadValue | null; + onChange: (next: LeadValue | null) => void; + onClose: () => void; +} + +type RowItem = + | { kind: "member"; member: MemberWithUser } + | { kind: "agent"; agent: Agent }; + +export function ProjectLeadPickerSheet({ + visible, + value, + onChange, + onClose, +}: Props) { + const wsId = useWorkspaceStore((s) => s.currentWorkspaceId); + const { data: members, isLoading: loadingMembers } = useQuery( + memberListOptions(wsId), + ); + const { data: agents, isLoading: loadingAgents } = useQuery( + agentListOptions(wsId), + ); + + const [query, setQuery] = useState(""); + + const sections = useMemo(() => { + const q = query.trim().toLowerCase(); + const memberRows: RowItem[] = (members ?? []) + .filter((m) => !q || m.name.toLowerCase().includes(q)) + .map((m) => ({ kind: "member" as const, member: m })); + const agentRows: RowItem[] = (agents ?? []) + .filter((a) => !q || a.name.toLowerCase().includes(q)) + .map((a) => ({ kind: "agent" as const, agent: a })); + const out: Array<{ title: string; data: RowItem[] }> = []; + if (memberRows.length > 0) out.push({ title: "Members", data: memberRows }); + if (agentRows.length > 0) out.push({ title: "Agents", data: agentRows }); + return out; + }, [members, agents, query]); + + const pick = (next: LeadValue | null) => { + onChange(next); + onClose(); + }; + + const matches = (item: RowItem) => { + if (!value) return false; + if (item.kind === "member") { + return value.type === "member" && value.id === item.member.user_id; + } + return value.type === "agent" && value.id === item.agent.id; + }; + + return ( + + + + {}} className="w-full max-w-sm"> + + + + + {loadingMembers || loadingAgents ? ( + + + + ) : ( + + item.kind === "member" + ? `m-${item.member.user_id}` + : `a-${item.agent.id}` + } + style={{ maxHeight: 420 }} + ListHeaderComponent={ + pick(null)} + /> + } + renderSectionHeader={({ section }) => ( + + + {section.title} + + + )} + renderItem={({ item }) => + item.kind === "member" ? ( + + pick({ type: "member", id: item.member.user_id }) + } + /> + ) : ( + pick({ type: "agent", id: item.agent.id })} + /> + ) + } + ListEmptyComponent={ + + + {query + ? "No matches." + : "No members or agents in this workspace yet."} + + + } + /> + )} + + + + + + ); +} + +function UnassignedRow({ + checked, + onPress, +}: { + checked: boolean; + onPress: () => void; +}) { + return ( + + + Unassigned + {checked ? : null} + + ); +} + +function PickerRow({ + name, + type, + id, + checked, + onPress, +}: { + name: string; + type: "member" | "agent"; + id: string; + checked: boolean; + onPress: () => void; +}) { + return ( + + + + {name} + + {checked ? : null} + + ); +} diff --git a/apps/mobile/components/project/pickers/project-priority-picker-sheet.tsx b/apps/mobile/components/project/pickers/project-priority-picker-sheet.tsx new file mode 100644 index 000000000..1254a8c3b --- /dev/null +++ b/apps/mobile/components/project/pickers/project-priority-picker-sheet.tsx @@ -0,0 +1,69 @@ +/** + * Project priority picker. Single-select over 5 ProjectPriority enum values. + * Shell mirrors project-status-picker-sheet.tsx. + */ +import { Modal, Pressable, View } from "react-native"; +import type { ProjectPriority } from "@multica/core/types"; +import { Text } from "@/components/ui/text"; +import { ProjectPriorityIcon } from "@/components/ui/project-priority-icon"; +import { + PROJECT_PRIORITIES, + PROJECT_PRIORITY_LABEL, +} from "@/lib/project-status"; +import { cn } from "@/lib/utils"; + +interface Props { + visible: boolean; + value: ProjectPriority | string; + onChange: (next: ProjectPriority) => void; + onClose: () => void; +} + +export function ProjectPriorityPickerSheet({ + visible, + value, + onChange, + onClose, +}: Props) { + return ( + + + + {}} className="w-full max-w-sm"> + + {PROJECT_PRIORITIES.map((priority) => { + const selected = priority === value; + return ( + { + onChange(priority); + onClose(); + }} + className={cn( + "flex-row items-center gap-3 rounded-lg px-3 py-2.5 active:bg-secondary", + selected && "bg-secondary", + )} + > + + + {PROJECT_PRIORITY_LABEL[priority]} + + {selected ? ( + + ) : null} + + ); + })} + + + + + + ); +} diff --git a/apps/mobile/components/project/pickers/project-status-picker-sheet.tsx b/apps/mobile/components/project/pickers/project-status-picker-sheet.tsx new file mode 100644 index 000000000..795794881 --- /dev/null +++ b/apps/mobile/components/project/pickers/project-status-picker-sheet.tsx @@ -0,0 +1,73 @@ +/** + * Project status picker. Single-select over the 5 ProjectStatus enum values. + * Tap-to-apply (no confirm step); sheet auto-closes on selection. + * + * Modal shell mirrors issue/pickers/status-picker-sheet.tsx — same fade-in + * centered popover, same tap-outside-to-dismiss behavior, same selected-row + * styling. + */ +import { Modal, Pressable, View } from "react-native"; +import type { ProjectStatus } from "@multica/core/types"; +import { Text } from "@/components/ui/text"; +import { ProjectStatusIcon } from "@/components/ui/project-status-icon"; +import { + PROJECT_STATUSES, + PROJECT_STATUS_LABEL, +} from "@/lib/project-status"; +import { cn } from "@/lib/utils"; + +interface Props { + visible: boolean; + value: ProjectStatus | string; + onChange: (next: ProjectStatus) => void; + onClose: () => void; +} + +export function ProjectStatusPickerSheet({ + visible, + value, + onChange, + onClose, +}: Props) { + return ( + + + + {}} className="w-full max-w-sm"> + + {PROJECT_STATUSES.map((status) => { + const selected = status === value; + return ( + { + onChange(status); + onClose(); + }} + className={cn( + "flex-row items-center gap-3 rounded-lg px-3 py-2.5 active:bg-secondary", + selected && "bg-secondary", + )} + > + + + {PROJECT_STATUS_LABEL[status]} + + {selected ? ( + + ) : null} + + ); + })} + + + + + + ); +} diff --git a/apps/mobile/components/project/project-header-card.tsx b/apps/mobile/components/project/project-header-card.tsx new file mode 100644 index 000000000..c2e02a5d0 --- /dev/null +++ b/apps/mobile/components/project/project-header-card.tsx @@ -0,0 +1,49 @@ +/** + * Header card for the project detail screen. Large emoji icon centered above + * the title, with the description shown in full (no truncation) below. + * + * Mirrors the visual emphasis of web's `project-header.tsx` but in a single + * vertical stack instead of the web sidebar layout — phones don't have the + * horizontal real estate for a side-by-side header + properties layout. + */ +import { Pressable, View } from "react-native"; +import type { Project } from "@multica/core/types"; +import { Text } from "@/components/ui/text"; +import { ProjectIcon } from "@/components/ui/project-icon"; + +interface Props { + project: Project; + onEdit?: () => void; +} + +export function ProjectHeaderCard({ project, onEdit }: Props) { + return ( + + + + + {project.title} + + {project.description ? ( + + {project.description} + + ) : onEdit ? ( + + Add a description + + ) : null} + + + ); +} diff --git a/apps/mobile/components/project/project-properties-section.tsx b/apps/mobile/components/project/project-properties-section.tsx new file mode 100644 index 000000000..5fbec6eb6 --- /dev/null +++ b/apps/mobile/components/project/project-properties-section.tsx @@ -0,0 +1,150 @@ +/** + * Project properties section. Tappable rows for Status / Priority / Lead. + * Each row opens a picker sheet via the corresponding `onPress*` callback. + * + * Layout mirrors iOS Settings rows: label on left, current value on right + * with a disclosure chevron, full-width separator below each row. Tapping + * anywhere on the row triggers the picker. + * + * Lead supports both member and agent (Project.lead_type), resolved via + * useActorLookup so it shares the same lookup with my-issues + issue detail. + */ +import { Pressable, View } from "react-native"; +import Svg, { Path } from "react-native-svg"; +import type { Project } from "@multica/core/types"; +import { Text } from "@/components/ui/text"; +import { ActorAvatar } from "@/components/ui/actor-avatar"; +import { ProjectStatusIcon } from "@/components/ui/project-status-icon"; +import { ProjectPriorityIcon } from "@/components/ui/project-priority-icon"; +import { + projectPriorityLabel, + projectStatusLabel, +} from "@/lib/project-status"; +import { useActorLookup } from "@/data/use-actor-name"; + +interface Props { + project: Project; + onPressStatus: () => void; + onPressPriority: () => void; + onPressLead: () => void; +} + +export function ProjectPropertiesSection({ + project, + onPressStatus, + onPressPriority, + onPressLead, +}: Props) { + const { getName } = useActorLookup(); + const leadName = + project.lead_type && project.lead_id + ? getName(project.lead_type, project.lead_id) + : null; + + return ( + + } + right={ + + {projectStatusLabel(project.status)} + + } + /> + + } + right={ + + {projectPriorityLabel(project.priority)} + + } + /> + + + ) : ( + + ) + } + right={ + + {leadName ?? "Unassigned"} + + } + /> + + ); +} + +function Row({ + label, + onPress, + left, + right, +}: { + label: string; + onPress: () => void; + left: React.ReactNode; + right: React.ReactNode; +}) { + return ( + + {label} + + {left} + {right} + + + + ); +} + +function Separator() { + return ; +} + +function Chevron() { + return ( + + + + ); +} + +function PlaceholderAvatar() { + return ( + + ); +} diff --git a/apps/mobile/components/project/project-related-issues.tsx b/apps/mobile/components/project/project-related-issues.tsx new file mode 100644 index 000000000..29df2b24e --- /dev/null +++ b/apps/mobile/components/project/project-related-issues.tsx @@ -0,0 +1,223 @@ +/** + * Issues belonging to a project. Two-bucket FlatList: Open and Done. + * + * Status grouping mirrors web's project detail: anything except `done` and + * `cancelled` is bucketed as Open. `cancelled` is shown in the Done bucket + * (web does the same — once a project's issue is cancelled it's effectively + * out of the active work pile). + * + * Behavioral parity: row content uses the same priority icon + identifier + * + title + status icon layout as my-issues IssueRow so the visual identity + * is consistent across surfaces. + */ +import { useMemo, useState } from "react"; +import { ActivityIndicator, LayoutAnimation, Pressable, View } from "react-native"; +import { useQuery } from "@tanstack/react-query"; +import { router } from "expo-router"; +import Svg, { Path } from "react-native-svg"; +import type { Issue } from "@multica/core/types"; +import { Text } from "@/components/ui/text"; +import { PriorityIcon } from "@/components/ui/priority-icon"; +import { StatusIcon } from "@/components/ui/status-icon"; +import { ActorAvatar } from "@/components/ui/actor-avatar"; +import { projectIssuesOptions } from "@/data/queries/projects"; +import { useWorkspaceStore } from "@/data/workspace-store"; + +interface Props { + projectId: string; +} + +const DONE_STATUSES = new Set(["done", "cancelled"]); + +export function ProjectRelatedIssues({ projectId }: Props) { + const wsId = useWorkspaceStore((s) => s.currentWorkspaceId); + const wsSlug = useWorkspaceStore((s) => s.currentWorkspaceSlug); + const { data, isLoading, error } = useQuery( + projectIssuesOptions(wsId, projectId), + ); + + // Open expanded by default (active work); Done collapsed (housekeeping). + // Matches iOS Notes / Reminders pattern of "show what needs attention, + // hide what's done unless asked". + const [openExpanded, setOpenExpanded] = useState(true); + const [doneExpanded, setDoneExpanded] = useState(false); + + const toggleOpen = () => { + // Native one-shot LayoutAnimation gives a smooth iOS-feeling + // expand/collapse without pulling in reanimated. + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); + setOpenExpanded((v) => !v); + }; + const toggleDone = () => { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); + setDoneExpanded((v) => !v); + }; + + const { open, done } = useMemo(() => { + const open: Issue[] = []; + const done: Issue[] = []; + for (const issue of data ?? []) { + if (DONE_STATUSES.has(issue.status)) { + done.push(issue); + } else { + open.push(issue); + } + } + return { open, done }; + }, [data]); + + const navigateToIssue = (id: string) => { + if (wsSlug) router.push(`/${wsSlug}/issue/${id}`); + }; + + if (isLoading) { + return ( + + + + ); + } + + if (error) { + return ( + + + Failed to load issues:{" "} + {error instanceof Error ? error.message : "unknown error"} + + + ); + } + + const total = open.length + done.length; + if (total === 0) { + return ( + + + No issues yet. + + + ); + } + + return ( + + + {openExpanded + ? open.map((issue) => ( + navigateToIssue(issue.id)} + /> + )) + : null} + {done.length > 0 ? ( + <> + + {doneExpanded + ? done.map((issue) => ( + navigateToIssue(issue.id)} + /> + )) + : null} + + ) : null} + + ); +} + +function SectionHeader({ + title, + count, + expanded, + onToggle, +}: { + title: string; + count: number; + expanded: boolean; + onToggle: () => void; +}) { + return ( + + + + {title} + + {count} + + ); +} + +function Chevron({ expanded }: { expanded: boolean }) { + // ▶ at rest, rotates to ▼ when expanded. Drawn as right-pointing in the + // SVG so the rotation transform reads as "open" without flipping + // orientation per state. + return ( + + + + + + ); +} + +function IssueRow({ + issue, + onPress, +}: { + issue: Issue; + onPress: () => void; +}) { + return ( + + + + + + {issue.identifier} + + + {issue.title} + + {issue.assignee_id && + (issue.assignee_type === "member" || issue.assignee_type === "agent") ? ( + + ) : null} + + + ); +} diff --git a/apps/mobile/components/project/project-resources-section.tsx b/apps/mobile/components/project/project-resources-section.tsx new file mode 100644 index 000000000..a98aae1c7 --- /dev/null +++ b/apps/mobile/components/project/project-resources-section.tsx @@ -0,0 +1,145 @@ +/** + * Project resources section. Read-mostly list of typed external pointers + * (today: GitHub repos). Tap a row to open the URL in the system browser. + * Long-press for delete (Pressable's onLongPress). + * + * Schema-tolerant by design — `resource_ref` is typed `unknown` in the + * mobile schema (server may extend the shape per resource_type). We narrow + * via `getRepoUrl()` only when the dispatch knows the type, so a future + * resource_type renders as a generic row with the label instead of crashing. + */ +import { ActivityIndicator, Alert, Linking, Pressable, View } from "react-native"; +import { useQuery } from "@tanstack/react-query"; +import { Ionicons } from "@expo/vector-icons"; +import type { + GithubRepoResourceRef, + ProjectResource, +} from "@multica/core/types"; +import { Text } from "@/components/ui/text"; +import { projectResourcesOptions } from "@/data/queries/projects"; +import { useDeleteProjectResource } from "@/data/mutations/projects"; +import { useWorkspaceStore } from "@/data/workspace-store"; + +interface Props { + projectId: string; + onAdd: () => void; +} + +export function ProjectResourcesSection({ projectId, onAdd }: Props) { + const wsId = useWorkspaceStore((s) => s.currentWorkspaceId); + const { data: resources, isLoading } = useQuery( + projectResourcesOptions(wsId, projectId), + ); + const remove = useDeleteProjectResource(projectId); + + const onOpen = async (resource: ProjectResource) => { + const url = getResourceUrl(resource); + if (!url) return; + const canOpen = await Linking.canOpenURL(url); + if (canOpen) { + await Linking.openURL(url); + } + }; + + const onLongPress = (resource: ProjectResource) => { + Alert.alert( + "Detach resource?", + describeResource(resource), + [ + { text: "Cancel", style: "cancel" }, + { + text: "Detach", + style: "destructive", + onPress: () => remove.mutate(resource.id), + }, + ], + ); + }; + + return ( + + + + Resources + + + Add + + + {isLoading ? ( + + + + ) : !resources || resources.length === 0 ? ( + + + No resources attached. + + + ) : ( + resources.map((resource) => ( + onOpen(resource)} + onLongPress={() => onLongPress(resource)} + /> + )) + )} + + ); +} + +function ResourceRow({ + resource, + onPress, + onLongPress, +}: { + resource: ProjectResource; + onPress: () => void; + onLongPress: () => void; +}) { + return ( + + + + + {resource.label ?? describeResource(resource)} + + {resource.label ? ( + + {describeResource(resource)} + + ) : null} + + + ); +} + +function iconFor(type: string): keyof typeof Ionicons.glyphMap { + if (type === "github_repo") return "logo-github"; + return "link-outline"; +} + +function getResourceUrl(resource: ProjectResource): string | null { + if (resource.resource_type === "github_repo") { + const ref = resource.resource_ref as GithubRepoResourceRef | undefined; + return ref?.url ?? null; + } + // Unknown type — try a `.url` field as a generic fallback. + const ref = resource.resource_ref as { url?: unknown } | undefined; + return typeof ref?.url === "string" ? ref.url : null; +} + +function describeResource(resource: ProjectResource): string { + return getResourceUrl(resource) ?? resource.resource_type; +} diff --git a/apps/mobile/components/project/project-row.tsx b/apps/mobile/components/project/project-row.tsx new file mode 100644 index 000000000..7f7538df7 --- /dev/null +++ b/apps/mobile/components/project/project-row.tsx @@ -0,0 +1,77 @@ +/** + * Project list row. Mirrors the IssueRow layout shape from + * `(tabs)/my-issues.tsx` (left icon + flex title + right column for + * counts + time), per apps/mobile/CLAUDE.md "Visual alignment is baseline + * → row's right-side elements stack vertically into a column". + * + * Layout: + * [📦 icon] Project title [3/12] + * [● in progress] [▍▍ high] 2d ago + */ +import { Pressable, View } from "react-native"; +import type { Project } from "@multica/core/types"; +import { Text } from "@/components/ui/text"; +import { ProjectIcon } from "@/components/ui/project-icon"; +import { ProjectStatusIcon } from "@/components/ui/project-status-icon"; +import { ProjectPriorityIcon } from "@/components/ui/project-priority-icon"; +import { + projectPriorityLabel, + projectStatusLabel, +} from "@/lib/project-status"; +import { timeAgo } from "@/lib/time-ago"; + +interface Props { + project: Project; + onPress: () => void; +} + +export function ProjectRow({ project, onPress }: Props) { + const totalIssues = project.issue_count; + const showCount = totalIssues > 0; + + return ( + + + + + + + + {project.title} + + + + + + {projectStatusLabel(project.status)} + + + {project.priority !== "none" ? ( + + + + {projectPriorityLabel(project.priority)} + + + ) : null} + + + + {showCount ? ( + + {project.done_count}/{totalIssues} + + ) : ( + + )} + + {timeAgo(project.updated_at)} + + + + + ); +} diff --git a/apps/mobile/components/ui/project-priority-icon.tsx b/apps/mobile/components/ui/project-priority-icon.tsx new file mode 100644 index 000000000..df2a78920 --- /dev/null +++ b/apps/mobile/components/ui/project-priority-icon.tsx @@ -0,0 +1,71 @@ +/** + * Mobile ProjectPriorityIcon — reuses the same 4-bar geometry as the issue + * PriorityIcon. Project priority enum is identical to issue priority enum + * (urgent/high/medium/low/none), so visually identical bars communicate the + * same meaning across surfaces — desirable for behavioral parity. + * + * Colors are kept identical to the issue PriorityIcon hex map. + */ +import Svg, { Line, Rect } from "react-native-svg"; +import type { ProjectPriority } from "@multica/core/types"; +import { projectPriorityBars } from "@/lib/project-status"; + +const COLOR: Record = { + urgent: "#dc2626", + high: "#eab308", + medium: "#eab308", + low: "#3b82f6", + none: "#71717a", +}; + +function colorFor(priority: string): string { + return (COLOR as Record)[priority] ?? COLOR.none; +} + +export function ProjectPriorityIcon({ + priority, + size = 14, +}: { + priority: ProjectPriority | string; + size?: number; +}) { + const filled = projectPriorityBars(priority); + const color = colorFor(priority); + + if (filled === 0) { + return ( + + + + ); + } + + return ( + + {[0, 1, 2, 3].map((i) => { + const y = 12 - (i + 1) * 3; + const h = (i + 1) * 3; + return ( + + ); + })} + + ); +} diff --git a/apps/mobile/components/ui/project-status-icon.tsx b/apps/mobile/components/ui/project-status-icon.tsx new file mode 100644 index 000000000..a39a381d3 --- /dev/null +++ b/apps/mobile/components/ui/project-status-icon.tsx @@ -0,0 +1,130 @@ +/** + * Mobile ProjectStatusIcon — visual identity per status, mirrors the design + * intent of the web `project-status-icon` (and the issue StatusIcon shape). + * + * Geometry follows status-icon.tsx (14×14 viewBox, 6r outer ring) so the + * project status icon visually rhymes with issue statuses on the same screen. + */ +import * as React from "react"; +import Svg, { Circle, Line, Path } from "react-native-svg"; +import type { ProjectStatus } from "@multica/core/types"; +import { projectStatusColor } from "@/lib/project-status"; + +const CX = 7; +const CY = 7; +const OUTER_R = 6; +const FILL_R = 3.5; + +function piePath(progress: number): string { + const angle = 2 * Math.PI * progress; + const endX = CX + FILL_R * Math.sin(angle); + const endY = CY - FILL_R * Math.cos(angle); + const largeArc = progress > 0.5 ? 1 : 0; + return `M${CX},${CY} L${CX},${CY - FILL_R} A${FILL_R},${FILL_R} 0 ${largeArc},1 ${endX},${endY} Z`; +} + +function Ring({ + color, + children, +}: { + color: string; + children?: React.ReactNode; +}) { + return ( + <> + + {children} + + ); +} + +function PauseBars({ color }: { color: string }) { + return ( + <> + + + + ); +} + +function CancelX({ color }: { color: string }) { + return ( + + ); +} + +function DoneCheck() { + return ( + + ); +} + +export function ProjectStatusIcon({ + status, + size = 16, +}: { + status: ProjectStatus | string; + size?: number; +}) { + const color = projectStatusColor(status); + return ( + + {status === "planned" ? ( + + ) : status === "in_progress" ? ( + + + + ) : status === "paused" ? ( + + + + ) : status === "completed" ? ( + <> + + + + ) : status === "cancelled" ? ( + + + + ) : ( + // Unknown server enum value — render the planned ring so the row + // still reads as "a project" rather than crashing or going blank. + + )} + + ); +} diff --git a/apps/mobile/data/api.ts b/apps/mobile/data/api.ts index c6b63d3fb..8152bf3be 100644 --- a/apps/mobile/data/api.ts +++ b/apps/mobile/data/api.ts @@ -21,6 +21,8 @@ import type { ChatSession, Comment, CreateIssueRequest, + CreateProjectRequest, + CreateProjectResourceRequest, InboxItem, Issue, IssueLabelsResponse, @@ -28,12 +30,16 @@ import type { ListIssuesParams, ListIssuesResponse, ListLabelsResponse, + ListProjectResourcesResponse, ListProjectsResponse, MemberWithUser, + Project, + ProjectResource, Reaction, SendChatMessageResponse, TimelinePage, UpdateIssueRequest, + UpdateProjectRequest, User, Workspace, } from "@multica/core/types"; @@ -53,9 +59,13 @@ import { EMPTY_CHAT_PENDING_TASK, EMPTY_CHAT_SESSION_LIST, EMPTY_LIST_LABELS_RESPONSE, + EMPTY_LIST_PROJECT_RESOURCES_RESPONSE, EMPTY_LIST_PROJECTS_RESPONSE, + EMPTY_PROJECT, ListLabelsResponseSchema, + ListProjectResourcesResponseSchema, ListProjectsResponseSchema, + ProjectSchema, SendChatMessageResponseSchema, } from "./schemas"; import { getCurrentSlug } from "./workspace-store"; @@ -487,6 +497,86 @@ class ApiClient { ); } + async getProject( + id: string, + opts?: { signal?: AbortSignal }, + ): Promise { + const raw = await this.fetch(`/api/projects/${id}`, { + signal: opts?.signal, + }); + // Drift-safe parse — UI checks `data.id === ""` to render the + // "project not found / shape drifted" error state instead of a + // half-populated detail page. + return parseWithFallback(raw, ProjectSchema, EMPTY_PROJECT, { + endpoint: "GET /api/projects/:id", + }); + } + + // Write endpoints — no parseWithFallback (mirrors updateIssue:430). A + // malformed write response surfaces as an error so the optimistic + // patch rolls back; pretending the write succeeded with empty data + // would silently desync caches. + async createProject(body: CreateProjectRequest): Promise { + return this.fetch("/api/projects", { + method: "POST", + body: JSON.stringify(body), + }); + } + + async updateProject( + id: string, + body: UpdateProjectRequest, + ): Promise { + return this.fetch(`/api/projects/${id}`, { + method: "PUT", + body: JSON.stringify(body), + }); + } + + async deleteProject(id: string): Promise { + await this.fetch(`/api/projects/${id}`, { method: "DELETE" }); + } + + // --- Project resources --- + async listProjectResources( + projectId: string, + opts?: { signal?: AbortSignal }, + ): Promise { + const raw = await this.fetch( + `/api/projects/${projectId}/resources`, + { signal: opts?.signal }, + ); + return parseWithFallback( + raw, + ListProjectResourcesResponseSchema, + EMPTY_LIST_PROJECT_RESOURCES_RESPONSE, + { endpoint: "GET /api/projects/:id/resources" }, + ); + } + + async createProjectResource( + projectId: string, + body: CreateProjectResourceRequest, + ): Promise { + return this.fetch( + `/api/projects/${projectId}/resources`, + { + method: "POST", + body: JSON.stringify(body), + }, + ); + } + + async deleteProjectResource( + projectId: string, + resourceId: string, + ): Promise { + await this.fetch( + `/api/projects/${projectId}/resources/${resourceId}`, + { method: "DELETE" }, + ); + } + // --- Chat --- // Mirrors the surface area of packages/core/api/client.ts chat methods. // v1 omits getChatSession + updateChatSession (rename) — see the v1 cut diff --git a/apps/mobile/data/mutations/projects.ts b/apps/mobile/data/mutations/projects.ts new file mode 100644 index 000000000..770b7f913 --- /dev/null +++ b/apps/mobile/data/mutations/projects.ts @@ -0,0 +1,204 @@ +/** + * Project mutations. Mirrors the optimistic-patch + event-always-wins pattern + * of `useUpdateIssue` (data/mutations/issues.ts:276): apply the patch to + * both list and detail caches up-front, server response or WS event later + * overwrites with authoritative state. + * + * Cache shapes touched: + * - projectKeys.list(wsId) → `Project[]` (patch in place) + * - projectKeys.detail(wsId,id) → `Project` (replace fully) + * - projectKeys.resources(...) → `ProjectResource[]` (append / filter) + * + * No realtime-driven `project:*` updaters exist on web yet (see + * apps/mobile/CLAUDE.md realtime section) so mobile mirrors the design + * — mobile-owned ws-updaters live in `data/realtime/project-ws-updaters.ts` + * and are invoked by `use-projects-realtime.ts` + `use-project-realtime.ts`. + */ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { + CreateProjectRequest, + CreateProjectResourceRequest, + Project, + ProjectResource, + UpdateProjectRequest, +} from "@multica/core/types"; +import { api } from "@/data/api"; +import { projectKeys } from "@/data/queries/projects"; +import { useWorkspaceStore } from "@/data/workspace-store"; + +export function useCreateProject() { + const qc = useQueryClient(); + const wsId = useWorkspaceStore((s) => s.currentWorkspaceId); + + return useMutation({ + mutationFn: (body: CreateProjectRequest) => api.createProject(body), + onSuccess: (project) => { + // Seed the detail cache so the post-create navigation lands on a + // populated page (no spinner flash). The list cache gets a prepend + // — list ordering is server-driven, so a brief out-of-order render + // is acceptable and corrected by the WS `project:created` event + // (or the next refetch). + qc.setQueryData(projectKeys.detail(wsId, project.id), project); + qc.setQueryData(projectKeys.list(wsId), (old) => + old ? [project, ...old.filter((p) => p.id !== project.id)] : [project], + ); + }, + }); +} + +export function useUpdateProject(projectId: string) { + const qc = useQueryClient(); + const wsId = useWorkspaceStore((s) => s.currentWorkspaceId); + + return useMutation({ + mutationKey: ["updateProject", projectId] as const, + mutationFn: (patch: UpdateProjectRequest) => + api.updateProject(projectId, patch), + onMutate: async (patch) => { + const detailKey = projectKeys.detail(wsId, projectId); + const listKey = projectKeys.list(wsId); + // Cancel both — a concurrent list refetch can race-overwrite the + // optimistic patch otherwise (brief stale flash on screen). + await Promise.all([ + qc.cancelQueries({ queryKey: detailKey }), + qc.cancelQueries({ queryKey: listKey }), + ]); + + const prevDetail = qc.getQueryData(detailKey); + const prevList = qc.getQueryData(listKey); + + if (prevDetail) { + qc.setQueryData(detailKey, { ...prevDetail, ...patch }); + } + qc.setQueryData(listKey, (old) => + old + ? old.map((p) => (p.id === projectId ? { ...p, ...patch } : p)) + : old, + ); + + return { prevDetail, prevList, detailKey, listKey }; + }, + onError: (_err, _vars, ctx) => { + if (!ctx) return; + if (ctx.prevDetail !== undefined) { + qc.setQueryData(ctx.detailKey, ctx.prevDetail); + } + if (ctx.prevList !== undefined) { + qc.setQueryData(ctx.listKey, ctx.prevList); + } + }, + onSuccess: (server) => { + // Server response is authoritative — replace the optimistic merge + // so any server-side normalisation (e.g. trimmed title) wins. + qc.setQueryData(projectKeys.detail(wsId, projectId), server); + qc.setQueryData(projectKeys.list(wsId), (old) => + old + ? old.map((p) => (p.id === projectId ? server : p)) + : old, + ); + }, + }); +} + +export function useDeleteProject(projectId: string) { + const qc = useQueryClient(); + const wsId = useWorkspaceStore((s) => s.currentWorkspaceId); + + return useMutation({ + mutationKey: ["deleteProject", projectId] as const, + mutationFn: () => api.deleteProject(projectId), + onMutate: async () => { + const listKey = projectKeys.list(wsId); + await qc.cancelQueries({ queryKey: listKey }); + const prevList = qc.getQueryData(listKey); + qc.setQueryData(listKey, (old) => + old ? old.filter((p) => p.id !== projectId) : old, + ); + return { prevList, listKey }; + }, + onError: (_err, _vars, ctx) => { + if (ctx?.prevList !== undefined) { + qc.setQueryData(ctx.listKey, ctx.prevList); + } + }, + onSettled: () => { + qc.removeQueries({ queryKey: projectKeys.detail(wsId, projectId) }); + qc.removeQueries({ queryKey: projectKeys.resources(wsId, projectId) }); + }, + }); +} + +export function useCreateProjectResource(projectId: string) { + const qc = useQueryClient(); + const wsId = useWorkspaceStore((s) => s.currentWorkspaceId); + + return useMutation({ + mutationKey: ["createProjectResource", projectId] as const, + mutationFn: (body: CreateProjectResourceRequest) => + api.createProjectResource(projectId, body), + onSuccess: (resource) => { + qc.setQueryData( + projectKeys.resources(wsId, projectId), + (old) => + old + ? [...old.filter((r) => r.id !== resource.id), resource] + : [resource], + ); + // Bump the parent's resource_count so the chip on detail/list + // increments without a refetch. + const bumpCount = (p: Project): Project => ({ + ...p, + resource_count: p.resource_count + 1, + }); + qc.setQueryData( + projectKeys.detail(wsId, projectId), + (old) => (old ? bumpCount(old) : old), + ); + qc.setQueryData(projectKeys.list(wsId), (old) => + old + ? old.map((p) => (p.id === projectId ? bumpCount(p) : p)) + : old, + ); + }, + }); +} + +export function useDeleteProjectResource(projectId: string) { + const qc = useQueryClient(); + const wsId = useWorkspaceStore((s) => s.currentWorkspaceId); + + return useMutation({ + mutationKey: ["deleteProjectResource", projectId] as const, + mutationFn: (resourceId: string) => + api.deleteProjectResource(projectId, resourceId).then(() => resourceId), + onMutate: async (resourceId) => { + const key = projectKeys.resources(wsId, projectId); + await qc.cancelQueries({ queryKey: key }); + const prev = qc.getQueryData(key); + qc.setQueryData(key, (old) => + old ? old.filter((r) => r.id !== resourceId) : old, + ); + return { prev, key }; + }, + onError: (_err, _vars, ctx) => { + if (ctx?.prev !== undefined) { + qc.setQueryData(ctx.key, ctx.prev); + } + }, + onSuccess: () => { + const dropCount = (p: Project): Project => ({ + ...p, + resource_count: Math.max(0, p.resource_count - 1), + }); + qc.setQueryData( + projectKeys.detail(wsId, projectId), + (old) => (old ? dropCount(old) : old), + ); + qc.setQueryData(projectKeys.list(wsId), (old) => + old + ? old.map((p) => (p.id === projectId ? dropCount(p) : p)) + : old, + ); + }, + }); +} diff --git a/apps/mobile/data/queries/projects.ts b/apps/mobile/data/queries/projects.ts index 9de9d40ee..f18d14302 100644 --- a/apps/mobile/data/queries/projects.ts +++ b/apps/mobile/data/queries/projects.ts @@ -1,18 +1,35 @@ /** - * Workspace project list. Consumed by the read-only project chip on issue - * detail and by `ProjectPickerSheet` in the new-issue / issue-detail flows. + * Workspace project queries. Three query shapes: + * + * - List (projectKeys.list) — `Project[]` + * - Detail (projectKeys.detail) — `Project` + * - Resources (projectKeys.resources) — `ProjectResource[]` (per project) + * + * Detail and Resources are workspace-scoped via the `wsId` segment so + * switching workspaces flips the cache without manual invalidate, per the + * root CLAUDE.md "Workspace-scoped queries must key on wsId" rule. + * + * Issues belonging to a project are NOT a project query — they live under + * `issueKeys.list(wsId, { project_id })` and reuse the issues cache shape. + * See `projectIssuesOptions` below for the binding helper. */ import { queryOptions } from "@tanstack/react-query"; import type { Project } from "@multica/core/types"; import { api } from "@/data/api"; +import { issueKeys } from "@/data/queries/issue-keys"; export const projectKeys = { all: (wsId: string | null) => ["projects", wsId] as const, + list: (wsId: string | null) => [...projectKeys.all(wsId), "list"] as const, + detail: (wsId: string | null, id: string) => + [...projectKeys.all(wsId), "detail", id] as const, + resources: (wsId: string | null, id: string) => + [...projectKeys.all(wsId), "detail", id, "resources"] as const, }; export const projectListOptions = (wsId: string | null) => queryOptions({ - queryKey: projectKeys.all(wsId), + queryKey: projectKeys.list(wsId), queryFn: async ({ signal }) => { const res = await api.listProjects({ signal }); return res.projects; @@ -20,6 +37,46 @@ export const projectListOptions = (wsId: string | null) => enabled: !!wsId, }); +export const projectDetailOptions = (wsId: string | null, id: string) => + queryOptions({ + queryKey: projectKeys.detail(wsId, id), + queryFn: ({ signal }) => api.getProject(id, { signal }), + enabled: !!wsId && !!id, + }); + +export const projectResourcesOptions = (wsId: string | null, id: string) => + queryOptions({ + queryKey: projectKeys.resources(wsId, id), + queryFn: async ({ signal }) => { + const res = await api.listProjectResources(id, { signal }); + return res.resources; + }, + enabled: !!wsId && !!id, + }); + +/** + * Issues filtered by `project_id`. Lives under the issues cache prefix + * (not the projects one) so a WS `issue:*` event invalidating + * `issueKeys.list(wsId)` also refreshes this list — single source of + * truth for issue caches. + */ +export const projectIssuesOptions = (wsId: string | null, projectId: string) => + queryOptions({ + queryKey: [ + ...issueKeys.list(wsId), + "byProject", + projectId, + ] as const, + queryFn: async ({ signal }) => { + const res = await api.listIssues( + { project_id: projectId }, + { signal }, + ); + return res.issues; + }, + enabled: !!wsId && !!projectId, + }); + /** * Helper for the read-only project chip — returns the project matching id, * or undefined. Caller selects from the list query and looks up by id. diff --git a/apps/mobile/data/realtime/project-ws-updaters.ts b/apps/mobile/data/realtime/project-ws-updaters.ts new file mode 100644 index 000000000..e20ab7924 --- /dev/null +++ b/apps/mobile/data/realtime/project-ws-updaters.ts @@ -0,0 +1,81 @@ +/** + * Mobile-owned WS cache patchers for the project domain. Pure functions over + * `QueryClient` — no React, no WS plumbing. Hooks in `use-projects-realtime.ts` + * and `use-project-realtime.ts` translate WS events into calls into this module. + * + * Why mobile-owned (and not importing from packages/core/projects): + * - Web doesn't have project ws-updaters yet — it invalidates via the + * query cache mutation surface. Mobile must patch (cellular-data rule + * in apps/mobile/CLAUDE.md realtime § "Patch over invalidate"). + * - Even when web adds them, mobile keys come from its own + * `data/queries/projects.ts` factory; binding to a foreign factory + * would silently drift on key-shape changes. + * + * Cache shapes: + * - Project list (projectKeys.list) → `Project[]` + * - Project detail (projectKeys.detail) → `Project` + * - Resources (projectKeys.resources) → `ProjectResource[]` + */ +import type { QueryClient } from "@tanstack/react-query"; +import type { Project } from "@multica/core/types"; +import { projectKeys } from "@/data/queries/projects"; + +export function patchProjectsList( + qc: QueryClient, + wsId: string, + partial: Partial & { id: string }, +) { + qc.setQueryData(projectKeys.list(wsId), (old) => + old + ? old.map((p) => (p.id === partial.id ? { ...p, ...partial } : p)) + : old, + ); +} + +/** Prepend if not present, replace in place if it is. List ordering is + * server-driven; on `project:created` the list will resync to the + * authoritative order via the next refetch / reconnect. */ +export function upsertIntoProjectsList( + qc: QueryClient, + wsId: string, + project: Project, +) { + qc.setQueryData(projectKeys.list(wsId), (old) => { + if (!old) return [project]; + const idx = old.findIndex((p) => p.id === project.id); + if (idx === -1) return [project, ...old]; + const copy = old.slice(); + copy[idx] = project; + return copy; + }); +} + +export function removeFromProjectsList( + qc: QueryClient, + wsId: string, + projectId: string, +) { + qc.setQueryData(projectKeys.list(wsId), (old) => + old ? old.filter((p) => p.id !== projectId) : old, + ); +} + +export function patchProjectDetail( + qc: QueryClient, + wsId: string, + project: Project, +) { + // Full replace — payload carries the authoritative Project. We don't merge + // because server can clear nullable fields (description / lead) which a + // partial spread would erase silently if the payload omitted the key. + qc.setQueryData(projectKeys.detail(wsId, project.id), project); +} + +export function clearProjectDetail( + qc: QueryClient, + wsId: string, + projectId: string, +) { + qc.removeQueries({ queryKey: projectKeys.detail(wsId, projectId) }); + qc.removeQueries({ queryKey: projectKeys.resources(wsId, projectId) }); +} diff --git a/apps/mobile/data/realtime/use-project-realtime.ts b/apps/mobile/data/realtime/use-project-realtime.ts new file mode 100644 index 000000000..f4607973b --- /dev/null +++ b/apps/mobile/data/realtime/use-project-realtime.ts @@ -0,0 +1,131 @@ +/** + * Per-project realtime subscriptions. Mounted by the project detail screen + * with the active project id; cleans up on navigate-away. + * + * Filters every event by id match (`project.id === projectId` for project + * events, `issue.project_id === projectId` for issue events) so the hook + * only mutates the caches it owns (apps/mobile/CLAUDE.md "Realtime → Mount + * strategy"). + * + * Handles: + * - project:updated → replace detail cache (payload is full Project). + * - project:deleted → drop detail + resources, then fire `onDeleted` so + * the screen pops back instead of stranding the user + * on a 404 page. + * - issue:updated/created/deleted → patch the related-issues cache so + * the list under the project stays in sync. + * Listing-level hooks (use-my-issues-realtime) only + * patch `issueKeys.myAll(wsId)`; this cache lives + * under `issueKeys.list(wsId)` with a "byProject" + * suffix and isn't covered by them. + * - reconnect → invalidate detail + resources + related-issues + * (we may have missed events while disconnected). + * + * `project:created` is not relevant to the per-record hook (no id match). + */ +import { useEffect } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import type { + Issue, + IssueCreatedPayload, + IssueDeletedPayload, + IssueUpdatedPayload, + ProjectDeletedPayload, + ProjectUpdatedPayload, +} from "@multica/core/types"; +import { issueKeys } from "@/data/queries/issue-keys"; +import { projectKeys } from "@/data/queries/projects"; +import { useWorkspaceStore } from "@/data/workspace-store"; +import { useWSClient } from "./realtime-provider"; +import { + clearProjectDetail, + patchProjectDetail, + removeFromProjectsList, +} from "./project-ws-updaters"; + +export function useProjectRealtime( + projectId: string | undefined, + onDeleted?: () => void, +) { + const ws = useWSClient(); + const wsId = useWorkspaceStore((s) => s.currentWorkspaceId); + const qc = useQueryClient(); + + useEffect(() => { + if (!ws || !wsId || !projectId) return; + + const issueListKey = [ + ...issueKeys.list(wsId), + "byProject", + projectId, + ] as const; + + const invalidateThisProject = () => { + qc.invalidateQueries({ queryKey: projectKeys.detail(wsId, projectId) }); + qc.invalidateQueries({ + queryKey: projectKeys.resources(wsId, projectId), + }); + qc.invalidateQueries({ queryKey: issueListKey }); + }; + + const unsubs: Array<() => void> = [ + // Project-level events + ws.on("project:updated", (p) => { + const payload = p as ProjectUpdatedPayload; + if (payload.project.id !== projectId) return; + patchProjectDetail(qc, wsId, payload.project); + }), + ws.on("project:deleted", (p) => { + const payload = p as ProjectDeletedPayload; + if (payload.project_id !== projectId) return; + clearProjectDetail(qc, wsId, projectId); + removeFromProjectsList(qc, wsId, projectId); + onDeleted?.(); + }), + + // Issue events for issues IN this project — patch the byProject cache + // directly so the list stays fresh without a refetch. + ws.on("issue:updated", (p) => { + const payload = p as IssueUpdatedPayload; + const issue = payload.issue; + // Status / project_id changes both matter: + // - if it was in this project and still is: replace in place + // - if it just moved INTO this project: append (server is authority on order) + // - if it just moved OUT: remove from this list + const wasInList = (qc.getQueryData(issueListKey) ?? []).some( + (i) => i.id === issue.id, + ); + const nowInProject = issue.project_id === projectId; + if (!wasInList && !nowInProject) return; + qc.setQueryData(issueListKey, (old) => { + if (!old) return old; + if (nowInProject) { + return old.some((i) => i.id === issue.id) + ? old.map((i) => (i.id === issue.id ? issue : i)) + : [...old, issue]; + } + return old.filter((i) => i.id !== issue.id); + }); + }), + ws.on("issue:created", (p) => { + const payload = p as IssueCreatedPayload; + if (payload.issue.project_id !== projectId) return; + // Server is the authority on list position — invalidate so we + // refetch with the correct ordering rather than guessing. + qc.invalidateQueries({ queryKey: issueListKey }); + }), + ws.on("issue:deleted", (p) => { + const payload = p as IssueDeletedPayload; + qc.setQueryData(issueListKey, (old) => + old ? old.filter((i) => i.id !== payload.issue_id) : old, + ); + }), + + ws.onReconnect(invalidateThisProject), + ]; + + return () => { + for (const unsub of unsubs) unsub(); + }; + }, [ws, wsId, projectId, qc, onDeleted]); +} diff --git a/apps/mobile/data/realtime/use-projects-realtime.ts b/apps/mobile/data/realtime/use-projects-realtime.ts new file mode 100644 index 000000000..5569643b9 --- /dev/null +++ b/apps/mobile/data/realtime/use-projects-realtime.ts @@ -0,0 +1,70 @@ +/** + * Projects realtime — listing-level subscriptions. Mounted globally + * (workspace-session-lifetime) alongside `useMyIssuesRealtime` so the + * project list stays fresh even if the user is on chat or an issue. + * + * Event coverage: + * - project:created → upsert into the list cache. The payload carries + * the full Project; no refetch. + * - project:updated → patch list + detail (full replace on detail). + * - project:deleted → strip from list and drop detail + resources caches. + * - reconnect → invalidate project list (we may have missed + * create/delete events while disconnected). + * + * Per the patch-over-invalidate rule in apps/mobile/CLAUDE.md "Realtime → + * Patch over invalidate (cellular-data rule)", every event with a full + * payload patches the cache directly. + */ +import { useEffect } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import type { + ProjectCreatedPayload, + ProjectDeletedPayload, + ProjectUpdatedPayload, +} from "@multica/core/types"; +import { projectKeys } from "@/data/queries/projects"; +import { useWorkspaceStore } from "@/data/workspace-store"; +import { useWSClient } from "./realtime-provider"; +import { + clearProjectDetail, + patchProjectDetail, + patchProjectsList, + removeFromProjectsList, + upsertIntoProjectsList, +} from "./project-ws-updaters"; + +export function useProjectsRealtime() { + const ws = useWSClient(); + const wsId = useWorkspaceStore((s) => s.currentWorkspaceId); + const qc = useQueryClient(); + + useEffect(() => { + if (!ws || !wsId) return; + + const invalidateList = () => { + qc.invalidateQueries({ queryKey: projectKeys.list(wsId) }); + }; + + const unsubs: Array<() => void> = [ + ws.on("project:created", (p) => { + const payload = p as ProjectCreatedPayload; + upsertIntoProjectsList(qc, wsId, payload.project); + }), + ws.on("project:updated", (p) => { + const payload = p as ProjectUpdatedPayload; + patchProjectsList(qc, wsId, payload.project); + patchProjectDetail(qc, wsId, payload.project); + }), + ws.on("project:deleted", (p) => { + const payload = p as ProjectDeletedPayload; + removeFromProjectsList(qc, wsId, payload.project_id); + clearProjectDetail(qc, wsId, payload.project_id); + }), + ws.onReconnect(invalidateList), + ]; + + return () => { + for (const unsub of unsubs) unsub(); + }; + }, [ws, wsId, qc]); +} diff --git a/apps/mobile/data/schemas.ts b/apps/mobile/data/schemas.ts index 19b69a905..f51c8d834 100644 --- a/apps/mobile/data/schemas.ts +++ b/apps/mobile/data/schemas.ts @@ -18,8 +18,10 @@ import type { IssueLabelsResponse, Label, ListLabelsResponse, + ListProjectResourcesResponse, ListProjectsResponse, Project, + ProjectResource, SendChatMessageResponse, } from "@multica/core/types"; @@ -72,7 +74,7 @@ export const EMPTY_ISSUE_LABELS_RESPONSE: IssueLabelsResponse = { labels: [], }; -const ProjectSchema = z.object({ +export const ProjectSchema = z.object({ id: z.string(), workspace_id: z.string(), title: z.string(), @@ -99,6 +101,55 @@ export const EMPTY_LIST_PROJECTS_RESPONSE: ListProjectsResponse = { total: 0, }; +// Fallback for `GET /api/projects/{id}` when the response shape drifts. +// `id` defaults to empty — caller can detect "not found / drift" by checking +// `data.id === ""` and rendering an error state instead of pretending the +// data is valid. Status / priority cast to the enum literals so TS callers +// downstream still flow correctly; runtime values came from the schema +// (`z.string()`), which would have already passed. +export const EMPTY_PROJECT: Project = { + id: "", + workspace_id: "", + title: "", + description: null, + icon: null, + status: "planned", + priority: "none", + lead_type: null, + lead_id: null, + created_at: "", + updated_at: "", + issue_count: 0, + done_count: 0, + resource_count: 0, +}; + +// Project resources are typed pointers to external resources (today: GitHub +// repos). resource_ref shape varies per resource_type; lenient on both +// `resource_type` (so a future type doesn't crash the list) and +// `resource_ref` (passes through unchanged for the renderer to dispatch on). +const ProjectResourceSchema = z.object({ + id: z.string(), + project_id: z.string(), + workspace_id: z.string(), + resource_type: z.string(), + resource_ref: z.unknown(), + label: z.string().nullable(), + position: z.number().default(0), + created_at: z.string(), + created_by: z.string().nullable(), +}).loose(); + +export const ListProjectResourcesResponseSchema = z.object({ + resources: z.array(ProjectResourceSchema).default([]), + total: z.number().default(0), +}).loose(); + +export const EMPTY_LIST_PROJECT_RESOURCES_RESPONSE: ListProjectResourcesResponse = { + resources: [], + total: 0, +}; + // ===================================================== // Chat (sessions / messages / pending task) // ===================================================== @@ -161,4 +212,4 @@ export const SendChatMessageResponseSchema: z.ZodType = }).loose(); // Helpers re-exported for ergonomic single-import at the call site. -export type { Label, Project }; +export type { Label, Project, ProjectResource }; diff --git a/apps/mobile/lib/project-status.ts b/apps/mobile/lib/project-status.ts new file mode 100644 index 000000000..e0476e1e8 --- /dev/null +++ b/apps/mobile/lib/project-status.ts @@ -0,0 +1,88 @@ +/** + * Mobile-owned project status + priority config. Mirror of + * `packages/core/projects/config.ts` — same enum order, same labels, same + * semantic colors. Mirrored (not imported) so mobile keeps full control of + * Tailwind tokens (we use the mobile tailwind palette, web/desktop use v4 + * tokens with different class names like `text-warning`). + * + * Behavioral parity (apps/mobile/CLAUDE.md "Behavioral parity"): + * - Status enum order is identical to web. All 5 values render — `cancelled` + * is NOT hidden. + * - Priority enum order is identical to web. `none` renders as "No + * priority", not as an absence. + * - Labels are the canonical English strings; i18n lands later when + * mobile picks an i18n lib (web uses i18next). + */ +import type { ProjectPriority, ProjectStatus } from "@multica/core/types"; + +export const PROJECT_STATUSES: ProjectStatus[] = [ + "planned", + "in_progress", + "paused", + "completed", + "cancelled", +]; + +export const PROJECT_PRIORITIES: ProjectPriority[] = [ + "urgent", + "high", + "medium", + "low", + "none", +]; + +export const PROJECT_STATUS_LABEL: Record = { + planned: "Planned", + in_progress: "In Progress", + paused: "Paused", + completed: "Completed", + cancelled: "Cancelled", +}; + +export const PROJECT_PRIORITY_LABEL: Record = { + urgent: "Urgent", + high: "High", + medium: "Medium", + low: "Low", + none: "No priority", +}; + +// Single hex per status, used by the SVG status icon (NativeWind classes +// can't be read by Svg props at runtime). Matches the semantic intent of +// the web tokens: planned/paused/cancelled are muted, in_progress is amber, +// completed is blue. +export const PROJECT_STATUS_COLOR: Record = { + planned: "#71717a", + in_progress: "#f59e0b", + paused: "#71717a", + completed: "#3b82f6", + cancelled: "#a1a1aa", +}; + +// Bar count for the priority icon (mirrors web's PROJECT_PRIORITY_CONFIG.bars). +export const PROJECT_PRIORITY_BARS: Record = { + urgent: 4, + high: 3, + medium: 2, + low: 1, + none: 0, +}; + +// Fallback for unknown server values per "Enum drift downgrades, not crashes" +// (root CLAUDE.md "API Response Compatibility"). Returns a sensible default +// so a future enum value still renders a labelled chip. +export function projectStatusLabel(value: string): string { + return (PROJECT_STATUS_LABEL as Record)[value] ?? value; +} + +export function projectPriorityLabel(value: string): string { + return (PROJECT_PRIORITY_LABEL as Record)[value] ?? value; +} + +export function projectStatusColor(value: string): string { + return (PROJECT_STATUS_COLOR as Record)[value] ?? "#71717a"; +} + +export function projectPriorityBars(value: string): number { + return (PROJECT_PRIORITY_BARS as Record)[value] ?? 0; +}