Files
multica/apps/mobile/components/issue/create-form-attribute-row.tsx
Bohan Jiang 5900d8b637 fix(issues): make start_date/due_date timezone-stable calendar days (#3618) (#3692)
* fix(issues): store start_date/due_date as DATE, not timestamp (MUL-2925)

These fields are calendar days (the pickers offer no time-of-day), but were
stored as TIMESTAMPTZ. A client serializing local midnight via toISOString()
folded its timezone into the instant, so the day shifted by the local offset
(GH #3618). Migrate the columns to DATE and parse/serialize date-only
"YYYY-MM-DD". ParseCalendarDate still accepts legacy RFC3339 (truncated to the
UTC day) so older clients keep working.

Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): render start_date/due_date as timezone-stable calendar days (MUL-2925)

Pickers now emit date-only "YYYY-MM-DD" (local calendar day) instead of
toISOString(), and every read formats via the shared @multica/core/issues/date
helpers with timeZone:"UTC" so the day never shifts with the viewer's offset.
The Gantt's existing UTC bucketing is now correct. Covers web/desktop pickers,
quick-set menu, list/board/detail/activity, and the mobile due-date picker.

Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): address date-only review — loud-fail ambiguous dates, finish display sweep (MUL-2925)

Review follow-ups on #3692:
- ParseCalendarDate no longer silently truncates a legacy non-midnight RFC3339
  to the wrong UTC day; it accepts only YYYY-MM-DD or an exact UTC-midnight
  instant and rejects ambiguous ones loudly. Adds util unit tests.
- migration 112 pins the TIMESTAMPTZ->DATE conversion to UTC explicitly via
  AT TIME ZONE 'UTC' (was session-timezone dependent); down migration too.
- Convert remaining date-change display sites to formatDateOnly: inbox detail
  label (web) and mobile activity + inbox labels (were new Date()+local format).
- CLI --start-date/--due-date help now says YYYY-MM-DD, not RFC3339.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-03 14:34:01 +08:00

139 lines
4.8 KiB
TypeScript

/**
* Bottom chip row for the new-issue form. Mirrors `attribute-row.tsx`'s
* visual pattern but operates on the `useNewIssueDraftStore` instead of an
* `issue` object + mutation. Tapping a chip pushes a formSheet picker
* route under `new-issue-picker/<field>` — the route reads/writes the same
* draft store, so the chip rehydrates automatically when the sheet
* dismisses.
*
* Why a draft store: the picker routes are siblings of new-issue.tsx in
* the Stack — they can't reach into the new-issue screen's local state.
* The draft store is the cross-screen channel.
*/
import { View } from "react-native";
import { router } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { AttributeChip } from "@/components/issue/attribute-chip";
import { ActorAvatar } from "@/components/ui/actor-avatar";
import { PriorityIcon } from "@/components/ui/priority-icon";
import { ProjectIcon } from "@/components/ui/project-icon";
import { StatusIcon } from "@/components/ui/status-icon";
import { formatDateOnly } from "@multica/core/issues/date";
import { useActorLookup } from "@/data/use-actor-name";
import { useNewIssueDraftStore } from "@/data/stores/new-issue-draft-store";
import { useWorkspaceStore } from "@/data/workspace-store";
import { PRIORITY_LABEL, STATUS_LABEL } from "@/lib/issue-status";
/**
* Picker fields the new-issue draft form can open. Bound to a typed map
* of Expo Router pathnames so typos become compile errors (previously
* the call site used `as never` on a template string).
*/
type NewIssuePickerField =
| "status"
| "priority"
| "assignee"
| "project"
| "due-date";
const NEW_ISSUE_PICKER_PATHNAMES = {
status: "/[workspace]/new-issue-picker/status",
priority: "/[workspace]/new-issue-picker/priority",
assignee: "/[workspace]/new-issue-picker/assignee",
project: "/[workspace]/new-issue-picker/project",
"due-date": "/[workspace]/new-issue-picker/due-date",
} as const satisfies Record<NewIssuePickerField, string>;
export function CreateFormAttributeRow() {
const wsSlug = useWorkspaceStore((s) => s.currentWorkspaceSlug);
const status = useNewIssueDraftStore((s) => s.status);
const priority = useNewIssueDraftStore((s) => s.priority);
const assignee = useNewIssueDraftStore((s) => s.assignee);
const dueDate = useNewIssueDraftStore((s) => s.dueDate);
const project = useNewIssueDraftStore((s) => s.project);
const { getName } = useActorLookup();
const assigneeLabel = assignee
? getName(assignee.type, assignee.id)
: "Assignee";
const priorityLabel =
priority === "none" ? "Priority" : PRIORITY_LABEL[priority];
const open = (field: NewIssuePickerField) => {
if (!wsSlug) return;
router.push({
pathname: NEW_ISSUE_PICKER_PATHNAMES[field],
params: { workspace: wsSlug },
});
};
return (
<View>
<View className="flex-row flex-wrap gap-2">
<AttributeChip
icon={<StatusIcon status={status} size={12} />}
label={STATUS_LABEL[status]}
variant="filled"
onPress={() => open("status")}
/>
<AttributeChip
icon={<PriorityIcon priority={priority} />}
label={priorityLabel}
variant={priority === "none" ? "dimmed" : "filled"}
onPress={() => open("priority")}
/>
<AttributeChip
icon={
assignee ? (
<ActorAvatar
type={assignee.type}
id={assignee.id}
size={16}
showPresence
/>
) : (
<Ionicons
name="person-circle-outline"
size={16}
color="#a1a1aa"
/>
)
}
label={assigneeLabel}
variant={assignee ? "filled" : "dimmed"}
onPress={() => open("assignee")}
/>
<AttributeChip
icon={
<Ionicons
name="calendar-outline"
size={14}
color={dueDate ? undefined : "#a1a1aa"}
/>
}
label={dueDate ? formatDueDate(dueDate) : "Due date"}
variant={dueDate ? "filled" : "dimmed"}
onPress={() => open("due-date")}
/>
<AttributeChip
icon={
project ? (
<ProjectIcon icon={project.icon} size="sm" />
) : (
<Ionicons name="folder-outline" size={14} color="#a1a1aa" />
)
}
label={project?.title ?? "Project"}
variant={project ? "filled" : "dimmed"}
onPress={() => open("project")}
/>
</View>
</View>
);
}
// due_date is a calendar day — format timezone-safely (no offset day shift).
function formatDueDate(iso: string): string {
return formatDateOnly(iso, { month: "short", day: "numeric" }) || "Due date";
}