Compare commits

...

3 Commits

Author SHA1 Message Date
J
85611c8612 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>
2026-06-03 13:57:18 +08:00
J
b1497d3738 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>
2026-06-03 13:31:36 +08:00
J
96896773e3 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>
2026-06-03 13:31:26 +08:00
29 changed files with 481 additions and 187 deletions

View File

@@ -17,6 +17,7 @@ import type {
IssueStatus,
IssuePriority,
} from "@multica/core/types";
import { formatDateOnly } from "@multica/core/issues/date";
import { Text } from "@/components/ui/text";
import { StatusIcon } from "@/components/ui/status-icon";
import { PriorityIcon } from "@/components/ui/priority-icon";
@@ -64,12 +65,9 @@ const TYPE_LABEL: Record<InboxItemType, string> = {
quick_create_failed: "Quick-create failed",
};
// due_date is a calendar day — format timezone-safely (no offset day shift).
function shortDate(dateStr: string): string {
if (!dateStr) return "";
return new Date(dateStr).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
return formatDateOnly(dateStr, { month: "short", day: "numeric" }, "en-US");
}
function singleLine(value: string | null | undefined): string {

View File

@@ -23,6 +23,7 @@ import type {
Issue,
IssuePriority,
} from "@multica/core/types";
import { formatDateOnly } from "@multica/core/issues/date";
import { Text } from "@/components/ui/text";
import { StatusIcon } from "@/components/ui/status-icon";
import { PriorityIcon } from "@/components/ui/priority-icon";
@@ -67,11 +68,11 @@ const ISSUE_PICKER_PATHNAMES = {
"due-date": "/[workspace]/issue/[id]/picker/due-date",
} as const satisfies Record<IssuePickerField, string>;
// due_date is a calendar day — format timezone-safely so the day never shifts
// with the viewer's offset. Mirrors web's formatDate in list-row/board-card.
function formatDueDate(iso: string | null): string | null {
if (!iso) return null;
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return null;
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
return formatDateOnly(iso, { month: "short", day: "numeric" }, "en-US") || null;
}
export function AttributeRow({ issue }: { issue: Issue }) {

View File

@@ -18,6 +18,7 @@ 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";
@@ -131,8 +132,7 @@ export function CreateFormAttributeRow() {
);
}
// due_date is a calendar day — format timezone-safely (no offset day shift).
function formatDueDate(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "Due date";
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
return formatDateOnly(iso, { month: "short", day: "numeric" }) || "Due date";
}

View File

@@ -3,40 +3,40 @@
* (a formSheet route) renders the Done / Clear actions in its own header
* area — this body only handles the picker spinner + the local draft state.
*
* Backend (server/internal/handler/issue.go CreateIssue / UpdateIssue) parses
* with time.Parse(time.RFC3339, ...) — strict. Mirrors web's
* packages/views/issues/components/pickers/due-date-picker.tsx which sends
* d.toISOString().
* due_date is a calendar day (date-only "YYYY-MM-DD", no time/timezone — see
* @multica/core/issues/date and GH #3618). Mirrors web's
* packages/views/issues/components/pickers/due-date-picker.tsx: read the stored
* day into a local-midnight Date for the spinner, write back the picked local
* day as a date-only string.
*/
import { useState, useEffect, useImperativeHandle, forwardRef } from "react";
import { View } from "react-native";
import DateTimePicker from "@react-native-community/datetimepicker";
import { toDateOnly, dateOnlyToLocalDate } from "@multica/core/issues/date";
interface Props {
value: string | null;
}
export interface DueDatePickerBodyHandle {
/** Returns the currently-displayed date as an ISO 8601 string. */
/** Returns the currently-displayed day as a date-only "YYYY-MM-DD" string. */
getIso: () => string;
}
function isoToDate(iso: string | null): Date {
if (!iso) return new Date();
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? new Date() : d;
function toLocalDay(value: string | null): Date {
return dateOnlyToLocalDate(value) ?? new Date();
}
export const DueDatePickerBody = forwardRef<DueDatePickerBodyHandle, Props>(
function DueDatePickerBody({ value }, ref) {
const [draft, setDraft] = useState<Date>(() => isoToDate(value));
const [draft, setDraft] = useState<Date>(() => toLocalDay(value));
useEffect(() => {
setDraft(isoToDate(value));
setDraft(toLocalDay(value));
}, [value]);
useImperativeHandle(ref, () => ({
getIso: () => draft.toISOString(),
getIso: () => toDateOnly(draft),
}));
return (

View File

@@ -15,6 +15,7 @@ import type {
IssueStatus,
TimelineEntry,
} from "@multica/core/types";
import { formatDateOnly } from "@multica/core/issues/date";
const STATUS_LABEL: Record<IssueStatus, string> = {
backlog: "Backlog",
@@ -44,12 +45,11 @@ function priorityName(p: string | undefined): string {
return p ?? "?";
}
// start_date / due_date are calendar days — format timezone-safely (no offset
// day shift). Mirrors web's formatActivity in issue-detail.tsx.
function shortDate(date: string | undefined): string {
if (!date) return "?";
return new Date(date).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
return formatDateOnly(date, { month: "short", day: "numeric" }, "en-US");
}
export function formatActivity(

View File

@@ -0,0 +1,66 @@
import { describe, it, expect } from "vitest";
import {
toDateOnly,
todayDateOnly,
addDaysDateOnly,
dateOnlyToUTCDate,
dateOnlyToLocalDate,
formatDateOnly,
isPastDateOnly,
} from "./date";
describe("issue date-only helpers", () => {
it("serializes a picked local day to YYYY-MM-DD with the local calendar", () => {
// A calendar picker hands back local midnight of the clicked day.
expect(toDateOnly(new Date(2026, 2, 1))).toBe("2026-03-01");
expect(toDateOnly(new Date(2026, 0, 5))).toBe("2026-01-05"); // zero-padded
});
it("formats a date-only string timezone-safely (no day shift)", () => {
// The bug: a calendar day must render as the same day in every timezone.
expect(
formatDateOnly("2026-03-01", { month: "short", day: "numeric" }, "en-US"),
).toBe("Mar 1");
expect(formatDateOnly("2026-03-01", undefined, "en-US")).toBe("Mar 1");
expect(formatDateOnly(null)).toBe("");
expect(formatDateOnly("")).toBe("");
});
it("round-trips a picked day back to the same displayed day", () => {
const picked = new Date(2026, 2, 1); // user clicks March 1 locally
const stored = toDateOnly(picked);
expect(stored).toBe("2026-03-01");
expect(formatDateOnly(stored, { month: "short", day: "numeric" }, "en-US")).toBe(
"Mar 1",
);
});
it("anchors a date-only value at UTC midnight", () => {
expect(dateOnlyToUTCDate("2026-03-01")?.toISOString()).toBe(
"2026-03-01T00:00:00.000Z",
);
expect(dateOnlyToUTCDate(null)).toBeNull();
});
it("tolerates a legacy RFC3339 instant by reading its UTC day", () => {
// Old clients stored local-midnight-as-UTC; read the stored UTC calendar day.
expect(dateOnlyToUTCDate("2026-02-28T16:00:00Z")?.toISOString()).toBe(
"2026-02-28T00:00:00.000Z",
);
});
it("builds a local-midnight Date for the picker's selected day", () => {
const d = dateOnlyToLocalDate("2026-03-01");
expect(d?.getFullYear()).toBe(2026);
expect(d?.getMonth()).toBe(2);
expect(d?.getDate()).toBe(1);
expect(dateOnlyToLocalDate(null)).toBeUndefined();
});
it("detects past calendar days relative to today", () => {
expect(isPastDateOnly(addDaysDateOnly(-1))).toBe(true);
expect(isPastDateOnly(todayDateOnly())).toBe(false);
expect(isPastDateOnly(addDaysDateOnly(1))).toBe(false);
expect(isPastDateOnly(null)).toBe(false);
});
});

View File

@@ -0,0 +1,97 @@
// Issue start_date / due_date are calendar days, not instants: the pickers
// offer no time-of-day input, so "Mar 1" must mean Mar 1 for every viewer
// regardless of timezone. They are transported as a date-only "YYYY-MM-DD"
// string. These helpers convert between that string and a Date WITHOUT letting
// the local timezone shift the day — the bug behind GH #3618 / MUL-2925 was
// serializing a local-midnight Date via toISOString() (which injects a tz) and
// reading it back through UTC day boundaries.
//
// Pure functions only (no React / DOM) so they can be shared with mobile.
const DATE_ONLY = /^(\d{4})-(\d{2})-(\d{2})/;
function pad(n: number): string {
return String(n).padStart(2, "0");
}
/**
* Serialize a Date the user picked in a calendar (local midnight of the chosen
* day) to a "YYYY-MM-DD" string, using the LOCAL calendar components so the
* stored day matches the day the user clicked.
*/
export function toDateOnly(date: Date): string {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
/** Today as a "YYYY-MM-DD" string in the viewer's local calendar. */
export function todayDateOnly(): string {
return toDateOnly(new Date());
}
/** "YYYY-MM-DD" of `days` from today in the viewer's local calendar. */
export function addDaysDateOnly(days: number): string {
const d = new Date();
d.setDate(d.getDate() + days);
return toDateOnly(d);
}
/**
* Parse a date-only string into [year, month, day], tolerating a legacy full
* ISO timestamp by reading its UTC calendar day. Returns null when unparseable.
*/
function parseParts(value: string): [number, number, number] | null {
const m = DATE_ONLY.exec(value);
if (m) return [Number(m[1]), Number(m[2]), Number(m[3])];
const d = new Date(value);
if (Number.isNaN(d.getTime())) return null;
return [d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate()];
}
/**
* Date anchored at UTC midnight of the calendar day. Use for timezone-safe
* display (format with `timeZone: "UTC"`), gantt day-bucketing, and
* chronological comparison.
*/
export function dateOnlyToUTCDate(
value: string | null | undefined,
): Date | null {
if (!value) return null;
const parts = parseParts(value);
if (!parts) return null;
return new Date(Date.UTC(parts[0], parts[1] - 1, parts[2]));
}
/**
* Date at LOCAL midnight of the calendar day. Use for a calendar picker's
* `selected` / `defaultMonth`, which match on the local-time day.
*/
export function dateOnlyToLocalDate(
value: string | null | undefined,
): Date | undefined {
if (!value) return undefined;
const parts = parseParts(value);
if (!parts) return undefined;
return new Date(parts[0], parts[1] - 1, parts[2]);
}
/**
* Format a calendar day for display, timezone-safely (the day never shifts with
* the viewer's timezone). Returns "" for an empty/unparseable value.
*/
export function formatDateOnly(
value: string | null | undefined,
options: Intl.DateTimeFormatOptions = { month: "short", day: "numeric" },
locale?: string,
): string {
const d = dateOnlyToUTCDate(value);
if (!d) return "";
return d.toLocaleDateString(locale, { ...options, timeZone: "UTC" });
}
/** True when the calendar day is strictly before today (viewer's local day). */
export function isPastDateOnly(value: string | null | undefined): boolean {
const d = dateOnlyToUTCDate(value);
if (!d) return false;
const today = dateOnlyToUTCDate(todayDateOnly());
return today != null && d.getTime() < today.getTime();
}

View File

@@ -29,6 +29,7 @@
"./issues/queries": "./issues/queries.ts",
"./issues/mutations": "./issues/mutations.ts",
"./issues/timeline-sort": "./issues/timeline-sort.ts",
"./issues/date": "./issues/date.ts",
"./issues/ws-updaters": "./issues/ws-updaters.ts",
"./issues/config": "./issues/config/index.ts",
"./issues/config/status": "./issues/config/status.ts",

View File

@@ -48,6 +48,9 @@ export interface Issue {
parent_issue_id: string | null;
project_id: string | null;
position: number;
// Calendar days as date-only "YYYY-MM-DD" (no time, no timezone). Use the
// helpers in @multica/core/issues/date to format/compare — never `new Date()`
// + local formatting, which shifts the day by the viewer's offset.
start_date: string | null;
due_date: string | null;
metadata: IssueMetadata;

View File

@@ -1,6 +1,7 @@
"use client";
import { STATUS_CONFIG, PRIORITY_CONFIG } from "@multica/core/issues/config";
import { formatDateOnly } from "@multica/core/issues/date";
import { useActorName } from "@multica/core/workspace/hooks";
import { StatusIcon, PriorityIcon } from "../../issues/components";
import type { InboxItem, InboxItemType, IssueStatus, IssuePriority } from "@multica/core/types";
@@ -33,12 +34,10 @@ export function useTypeLabels(): Record<InboxItemType, string> {
};
}
// start_date / due_date are calendar days — format timezone-safely so the day
// never shifts with the viewer's offset (see @multica/core/issues/date).
function shortDate(dateStr: string): string {
if (!dateStr) return "";
return new Date(dateStr).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
return formatDateOnly(dateStr, { month: "short", day: "numeric" }, "en-US");
}
export function InboxDetailLabel({ item }: { item: InboxItem }) {

View File

@@ -18,6 +18,7 @@ import {
UserMinus,
} from "lucide-react";
import type { AgentTask, Issue } from "@multica/core/types";
import { todayDateOnly, addDaysDateOnly } from "@multica/core/issues/date";
import { api } from "@multica/core/api";
import {
ALL_STATUSES,
@@ -106,13 +107,6 @@ export function IssueActionsMenuItems({
openDeleteConfirm,
} = actions;
const now = () => new Date();
const inDays = (days: number) => {
const d = new Date();
d.setDate(d.getDate() + days);
return d.toISOString();
};
// Subscribe to the issue's task list so the cache is warm by the time the
// user clicks "Copy local workdir path". The query only fires while the
// menu is open (Base UI portals the menu content lazily) — list views
@@ -205,13 +199,13 @@ export function IssueActionsMenuItems({
{t(($) => $.actions.start_date)}
</P.SubTrigger>
<P.SubContent>
<P.Item onClick={() => updateField({ start_date: now().toISOString() })}>
<P.Item onClick={() => updateField({ start_date: todayDateOnly() })}>
{t(($) => $.actions.start_today)}
</P.Item>
<P.Item onClick={() => updateField({ start_date: inDays(1) })}>
<P.Item onClick={() => updateField({ start_date: addDaysDateOnly(1) })}>
{t(($) => $.actions.start_tomorrow)}
</P.Item>
<P.Item onClick={() => updateField({ start_date: inDays(7) })}>
<P.Item onClick={() => updateField({ start_date: addDaysDateOnly(7) })}>
{t(($) => $.actions.start_next_week)}
</P.Item>
{issue.start_date && (
@@ -232,13 +226,13 @@ export function IssueActionsMenuItems({
{t(($) => $.actions.due_date)}
</P.SubTrigger>
<P.SubContent>
<P.Item onClick={() => updateField({ due_date: now().toISOString() })}>
<P.Item onClick={() => updateField({ due_date: todayDateOnly() })}>
{t(($) => $.actions.due_today)}
</P.Item>
<P.Item onClick={() => updateField({ due_date: inDays(1) })}>
<P.Item onClick={() => updateField({ due_date: addDaysDateOnly(1) })}>
{t(($) => $.actions.due_tomorrow)}
</P.Item>
<P.Item onClick={() => updateField({ due_date: inDays(7) })}>
<P.Item onClick={() => updateField({ due_date: addDaysDateOnly(7) })}>
{t(($) => $.actions.due_next_week)}
</P.Item>
{issue.due_date && (

View File

@@ -7,6 +7,7 @@ import type { AnimateLayoutChanges } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { toast } from "sonner";
import type { Issue, UpdateIssueRequest } from "@multica/core/types";
import { formatDateOnly, isPastDateOnly } from "@multica/core/issues/date";
import { CalendarClock, CalendarDays } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { ActorAvatar } from "../../common/actor-avatar";
@@ -28,10 +29,7 @@ import { IssueAgentActivityIndicator } from "./issue-agent-activity-indicator";
import { useT } from "../../i18n";
function formatDate(date: string): string {
return new Date(date).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
return formatDateOnly(date, { month: "short", day: "numeric" }, "en-US");
}
function descriptionPreview(markdown: string): string {
@@ -261,7 +259,7 @@ export const BoardCardContent = memo(function BoardCardContent({
trigger={
<span
className={`flex items-center gap-1 text-xs ${
new Date(issue.due_date!) < new Date()
isPastDateOnly(issue.due_date)
? "text-destructive"
: "text-muted-foreground"
}`}
@@ -275,7 +273,7 @@ export const BoardCardContent = memo(function BoardCardContent({
) : (
<span
className={`flex shrink-0 items-center gap-1 text-xs ${
new Date(issue.due_date!) < new Date()
isPastDateOnly(issue.due_date)
? "text-destructive"
: "text-muted-foreground"
}`}

View File

@@ -8,6 +8,7 @@ import { useViewStore, useViewStoreApi } from "@multica/core/issues/stores/view-
import type { GanttZoom } from "@multica/core/issues/stores/view-store";
import { projectListOptions } from "@multica/core/projects/queries";
import type { Issue, IssueStatus } from "@multica/core/types";
import { dateOnlyToUTCDate } from "@multica/core/issues/date";
import { cn } from "@multica/ui/lib/utils";
import {
Tooltip,
@@ -43,11 +44,11 @@ function daysBetween(a: Date, b: Date): number {
return Math.round((b.getTime() - a.getTime()) / MS_PER_DAY);
}
// Issue dates arrive as date-only "YYYY-MM-DD" strings (calendar days). Anchor
// each to UTC midnight so the bar lands on exactly that day, independent of the
// viewer's timezone. See @multica/core/issues/date.
function parseDay(iso: string | null): Date | null {
if (!iso) return null;
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return null;
return startOfDayUTC(d);
return dateOnlyToUTCDate(iso);
}
function isWeekendUTC(d: Date): boolean {

View File

@@ -44,6 +44,7 @@ import { ActorAvatar } from "../../common/actor-avatar";
import { PropRow } from "../../common/prop-row";
import type { Attachment, Issue, IssueStatus, IssuePriority, TimelineEntry, UpdateIssueRequest } from "@multica/core/types";
import { STATUS_CONFIG, PRIORITY_CONFIG } from "@multica/core/issues/config";
import { formatDateOnly } from "@multica/core/issues/date";
import { useUpdateIssue } from "@multica/core/issues/mutations";
import { toast } from "sonner";
import { StatusIcon, PriorityIcon, StatusPicker, PriorityPicker, StartDatePicker, DueDatePicker, AssigneePicker, LabelPicker } from ".";
@@ -168,10 +169,7 @@ function SubscriberPopoverContent({
function shortDate(date: string | null): string {
if (!date) return "—";
return new Date(date).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
return formatDateOnly(date, { month: "short", day: "numeric" }, "en-US");
}
type ActivityT = ReturnType<typeof useT<"issues">>["t"];
@@ -221,12 +219,12 @@ function formatActivity(
}
case "start_date_changed": {
if (!details.to) return t(($) => $.activity.start_date_removed);
const formatted = new Date(details.to).toLocaleDateString("en-US", { month: "short", day: "numeric" });
const formatted = formatDateOnly(details.to, { month: "short", day: "numeric" }, "en-US");
return t(($) => $.activity.start_date_set, { date: formatted });
}
case "due_date_changed": {
if (!details.to) return t(($) => $.activity.due_date_removed);
const formatted = new Date(details.to).toLocaleDateString("en-US", { month: "short", day: "numeric" });
const formatted = formatDateOnly(details.to, { month: "short", day: "numeric" }, "en-US");
return t(($) => $.activity.due_date_set, { date: formatted });
}
case "title_changed":

View File

@@ -7,6 +7,7 @@ import type { AnimateLayoutChanges } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { AppLink } from "../../navigation";
import type { Issue } from "@multica/core/types";
import { formatDateOnly } from "@multica/core/issues/date";
import { ActorAvatar } from "../../common/actor-avatar";
import { useIssueSelectionStore } from "@multica/core/issues/stores/selection-store";
import { useWorkspacePaths } from "@multica/core/paths";
@@ -26,10 +27,7 @@ export interface ChildProgress {
}
function formatDate(date: string): string {
return new Date(date).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
return formatDateOnly(date, { month: "short", day: "numeric" }, "en-US");
}
function ListRowContent({

View File

@@ -3,6 +3,12 @@
import { useState } from "react";
import { CalendarDays } from "lucide-react";
import type { UpdateIssueRequest } from "@multica/core/types";
import {
toDateOnly,
dateOnlyToLocalDate,
formatDateOnly,
isPastDateOnly,
} from "@multica/core/issues/date";
import { Calendar } from "@multica/ui/components/ui/calendar";
import {
Popover,
@@ -31,8 +37,8 @@ export function DueDatePicker({
}) {
const { t } = useT("issues");
const [open, setOpen] = useState(defaultOpen);
const date = dueDate ? new Date(dueDate) : undefined;
const isOverdue = date ? date < new Date() : false;
const date = dateOnlyToLocalDate(dueDate);
const isOverdue = isPastDateOnly(dueDate);
return (
<Popover open={open} onOpenChange={setOpen}>
@@ -45,7 +51,7 @@ export function DueDatePicker({
<CalendarDays className="h-3.5 w-3.5 text-muted-foreground" />
{date ? (
<span className={isOverdue ? "text-destructive" : ""}>
{date.toLocaleDateString("en-US", { month: "short", day: "numeric" })}
{formatDateOnly(dueDate, { month: "short", day: "numeric" }, "en-US")}
</span>
) : (
<span className="text-muted-foreground">{t(($) => $.pickers.due_date.trigger_label)}</span>
@@ -58,7 +64,7 @@ export function DueDatePicker({
mode="single"
selected={date}
onSelect={(d: Date | undefined) => {
onUpdate({ due_date: d ? d.toISOString() : null });
onUpdate({ due_date: d ? toDateOnly(d) : null });
setOpen(false);
}}
/>

View File

@@ -3,6 +3,11 @@
import { useState } from "react";
import { CalendarClock } from "lucide-react";
import type { UpdateIssueRequest } from "@multica/core/types";
import {
toDateOnly,
dateOnlyToLocalDate,
formatDateOnly,
} from "@multica/core/issues/date";
import { Calendar } from "@multica/ui/components/ui/calendar";
import {
Popover,
@@ -37,7 +42,7 @@ export function StartDatePicker({
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const open = controlledOpen ?? internalOpen;
const setOpen = controlledOnOpenChange ?? setInternalOpen;
const date = startDate ? new Date(startDate) : undefined;
const date = dateOnlyToLocalDate(startDate);
return (
<Popover open={open} onOpenChange={setOpen}>
@@ -50,7 +55,7 @@ export function StartDatePicker({
<CalendarClock className="h-3.5 w-3.5 text-muted-foreground" />
{date ? (
<span>
{date.toLocaleDateString("en-US", { month: "short", day: "numeric" })}
{formatDateOnly(startDate, { month: "short", day: "numeric" }, "en-US")}
</span>
) : (
<span className="text-muted-foreground">{t(($) => $.pickers.start_date.trigger_label)}</span>
@@ -63,7 +68,7 @@ export function StartDatePicker({
mode="single"
selected={date}
onSelect={(d: Date | undefined) => {
onUpdate({ start_date: d ? d.toISOString() : null });
onUpdate({ start_date: d ? toDateOnly(d) : null });
setOpen(false);
}}
/>

View File

@@ -291,8 +291,8 @@ func init() {
issueCreateCmd.Flags().String("assignee-id", "", "Assignee UUID — member, agent, or squad (mutually exclusive with --assignee)")
issueCreateCmd.Flags().String("parent", "", "Parent issue ID")
issueCreateCmd.Flags().String("project", "", "Project ID")
issueCreateCmd.Flags().String("start-date", "", "Start date (RFC3339 format)")
issueCreateCmd.Flags().String("due-date", "", "Due date (RFC3339 format)")
issueCreateCmd.Flags().String("start-date", "", "Start date (calendar day, YYYY-MM-DD)")
issueCreateCmd.Flags().String("due-date", "", "Due date (calendar day, YYYY-MM-DD)")
issueCreateCmd.Flags().Bool("allow-duplicate", false, "Allow creating an issue even when an active duplicate exists")
issueCreateCmd.Flags().String("output", "json", "Output format: table or json")
issueCreateCmd.Flags().StringSlice("attachment", nil, "File path(s) to attach (can be specified multiple times)")
@@ -307,8 +307,8 @@ func init() {
issueUpdateCmd.Flags().String("assignee", "", "New assignee name (member, agent, or squad; fuzzy match)")
issueUpdateCmd.Flags().String("assignee-id", "", "New assignee UUID — member, agent, or squad (mutually exclusive with --assignee)")
issueUpdateCmd.Flags().String("project", "", "Project ID")
issueUpdateCmd.Flags().String("start-date", "", "New start date (RFC3339 format; pass empty string to clear)")
issueUpdateCmd.Flags().String("due-date", "", "New due date (RFC3339 format)")
issueUpdateCmd.Flags().String("start-date", "", "New start date (calendar day, YYYY-MM-DD; pass empty string to clear)")
issueUpdateCmd.Flags().String("due-date", "", "New due date (calendar day, YYYY-MM-DD)")
issueUpdateCmd.Flags().String("parent", "", "Parent issue ID (use --parent \"\" to clear)")
issueUpdateCmd.Flags().String("output", "json", "Output format: table or json")

View File

@@ -753,7 +753,7 @@ func TestNotification_DueDateChanged(t *testing.T) {
addTestSubscriber(t, issueID, "member", testUserID, "creator")
addTestSubscriber(t, issueID, "member", sub1ID, "assignee")
dueDate := "2026-04-15T00:00:00Z"
dueDate := "2026-04-15"
bus.Publish(events.Event{
Type: protocol.EventIssueUpdated,
WorkspaceID: testWorkspaceID,
@@ -814,7 +814,7 @@ func TestNotification_StartDateChanged(t *testing.T) {
addTestSubscriber(t, issueID, "member", testUserID, "creator")
addTestSubscriber(t, issueID, "member", sub1ID, "assignee")
startDate := "2026-04-01T00:00:00Z"
startDate := "2026-04-01"
bus.Publish(events.Event{
Type: protocol.EventIssueUpdated,
WorkspaceID: testWorkspaceID,

View File

@@ -190,6 +190,7 @@ func ptrToText(s *string) pgtype.Text { return util.PtrToText(s) }
func strToText(s string) pgtype.Text { return util.StrToText(s) }
func timestampToString(t pgtype.Timestamptz) string { return util.TimestampToString(t) }
func timestampToPtr(t pgtype.Timestamptz) *string { return util.TimestampToPtr(t) }
func dateToPtr(d pgtype.Date) *string { return util.DateToPtr(d) }
func uuidToPtr(u pgtype.UUID) *string { return util.UUIDToPtr(u) }
func int8ToPtr(v pgtype.Int8) *int64 { return util.Int8ToPtr(v) }

View File

@@ -11,7 +11,6 @@ import (
"regexp"
"strconv"
"strings"
"time"
"unicode"
"github.com/go-chi/chi/v5"
@@ -28,25 +27,25 @@ import (
// IssueResponse is the JSON response for an issue.
type IssueResponse struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
Number int32 `json:"number"`
Identifier string `json:"identifier"`
Title string `json:"title"`
Description *string `json:"description"`
Status string `json:"status"`
Priority string `json:"priority"`
AssigneeType *string `json:"assignee_type"`
AssigneeID *string `json:"assignee_id"`
CreatorType string `json:"creator_type"`
CreatorID string `json:"creator_id"`
ParentIssueID *string `json:"parent_issue_id"`
ProjectID *string `json:"project_id"`
Position float64 `json:"position"`
StartDate *string `json:"start_date"`
DueDate *string `json:"due_date"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
Number int32 `json:"number"`
Identifier string `json:"identifier"`
Title string `json:"title"`
Description *string `json:"description"`
Status string `json:"status"`
Priority string `json:"priority"`
AssigneeType *string `json:"assignee_type"`
AssigneeID *string `json:"assignee_id"`
CreatorType string `json:"creator_type"`
CreatorID string `json:"creator_id"`
ParentIssueID *string `json:"parent_issue_id"`
ProjectID *string `json:"project_id"`
Position float64 `json:"position"`
StartDate *string `json:"start_date"`
DueDate *string `json:"due_date"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
// Metadata is the per-issue KV map (see issue_metadata.go). Always emitted
// (empty object when unset) so frontend code can `issue.metadata[key]`
// without nil-guarding the parent field.
@@ -80,8 +79,8 @@ func issueToResponse(i db.Issue, issuePrefix string) IssueResponse {
ParentIssueID: uuidToPtr(i.ParentIssueID),
ProjectID: uuidToPtr(i.ProjectID),
Position: i.Position,
StartDate: timestampToPtr(i.StartDate),
DueDate: timestampToPtr(i.DueDate),
StartDate: dateToPtr(i.StartDate),
DueDate: dateToPtr(i.DueDate),
CreatedAt: timestampToString(i.CreatedAt),
UpdatedAt: timestampToString(i.UpdatedAt),
Metadata: parseIssueMetadata(i.Metadata),
@@ -107,8 +106,8 @@ func issueListRowToResponse(i db.ListIssuesRow, issuePrefix string) IssueRespons
ParentIssueID: uuidToPtr(i.ParentIssueID),
ProjectID: uuidToPtr(i.ProjectID),
Position: i.Position,
StartDate: timestampToPtr(i.StartDate),
DueDate: timestampToPtr(i.DueDate),
StartDate: dateToPtr(i.StartDate),
DueDate: dateToPtr(i.DueDate),
CreatedAt: timestampToString(i.CreatedAt),
UpdatedAt: timestampToString(i.UpdatedAt),
Metadata: parseIssueMetadata(i.Metadata),
@@ -164,8 +163,8 @@ func openIssueRowToResponse(i db.ListOpenIssuesRow, issuePrefix string) IssueRes
ParentIssueID: uuidToPtr(i.ParentIssueID),
ProjectID: uuidToPtr(i.ProjectID),
Position: i.Position,
StartDate: timestampToPtr(i.StartDate),
DueDate: timestampToPtr(i.DueDate),
StartDate: dateToPtr(i.StartDate),
DueDate: dateToPtr(i.DueDate),
CreatedAt: timestampToString(i.CreatedAt),
UpdatedAt: timestampToString(i.UpdatedAt),
Metadata: parseIssueMetadata(i.Metadata),
@@ -199,10 +198,10 @@ func assigneeGroupID(assigneeType pgtype.Text, assigneeID pgtype.UUID) string {
// SearchIssueResponse extends IssueResponse with search metadata.
type SearchIssueResponse struct {
IssueResponse
MatchSource string `json:"match_source"`
MatchedSnippet *string `json:"matched_snippet,omitempty"`
MatchedDescriptionSnippet *string `json:"matched_description_snippet,omitempty"`
MatchedCommentSnippet *string `json:"matched_comment_snippet,omitempty"`
MatchSource string `json:"match_source"`
MatchedSnippet *string `json:"matched_snippet,omitempty"`
MatchedDescriptionSnippet *string `json:"matched_description_snippet,omitempty"`
MatchedCommentSnippet *string `json:"matched_comment_snippet,omitempty"`
}
// extractSnippet extracts a snippet of text around the first occurrence of query.
@@ -2051,24 +2050,24 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
return
}
var startDate pgtype.Timestamptz
var startDate pgtype.Date
if req.StartDate != nil && *req.StartDate != "" {
t, err := time.Parse(time.RFC3339, *req.StartDate)
d, err := util.ParseCalendarDate(*req.StartDate)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid start_date format, expected RFC3339")
writeError(w, http.StatusBadRequest, "invalid start_date format, expected YYYY-MM-DD")
return
}
startDate = pgtype.Timestamptz{Time: t, Valid: true}
startDate = d
}
var dueDate pgtype.Timestamptz
var dueDate pgtype.Date
if req.DueDate != nil && *req.DueDate != "" {
t, err := time.Parse(time.RFC3339, *req.DueDate)
d, err := util.ParseCalendarDate(*req.DueDate)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid due_date format, expected RFC3339")
writeError(w, http.StatusBadRequest, "invalid due_date format, expected YYYY-MM-DD")
return
}
dueDate = pgtype.Timestamptz{Time: t, Valid: true}
dueDate = d
}
// Use a transaction to atomically guard against active duplicates,
@@ -2363,26 +2362,26 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
}
if _, ok := rawFields["start_date"]; ok {
if req.StartDate != nil && *req.StartDate != "" {
t, err := time.Parse(time.RFC3339, *req.StartDate)
d, err := util.ParseCalendarDate(*req.StartDate)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid start_date format, expected RFC3339")
writeError(w, http.StatusBadRequest, "invalid start_date format, expected YYYY-MM-DD")
return
}
params.StartDate = pgtype.Timestamptz{Time: t, Valid: true}
params.StartDate = d
} else {
params.StartDate = pgtype.Timestamptz{Valid: false} // explicit null = clear date
params.StartDate = pgtype.Date{Valid: false} // explicit null = clear date
}
}
if _, ok := rawFields["due_date"]; ok {
if req.DueDate != nil && *req.DueDate != "" {
t, err := time.Parse(time.RFC3339, *req.DueDate)
d, err := util.ParseCalendarDate(*req.DueDate)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid due_date format, expected RFC3339")
writeError(w, http.StatusBadRequest, "invalid due_date format, expected YYYY-MM-DD")
return
}
params.DueDate = pgtype.Timestamptz{Time: t, Valid: true}
params.DueDate = d
} else {
params.DueDate = pgtype.Timestamptz{Valid: false} // explicit null = clear date
params.DueDate = pgtype.Date{Valid: false} // explicit null = clear date
}
}
if _, ok := rawFields["parent_issue_id"]; ok {
@@ -2474,10 +2473,10 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
priorityChanged := req.Priority != nil && prevIssue.Priority != issue.Priority
descriptionChanged := req.Description != nil && textToPtr(prevIssue.Description) != resp.Description
titleChanged := req.Title != nil && prevIssue.Title != issue.Title
prevStartDate := timestampToPtr(prevIssue.StartDate)
prevStartDate := dateToPtr(prevIssue.StartDate)
startDateChanged := prevStartDate != resp.StartDate && (prevStartDate == nil) != (resp.StartDate == nil) ||
(prevStartDate != nil && resp.StartDate != nil && *prevStartDate != *resp.StartDate)
prevDueDate := timestampToPtr(prevIssue.DueDate)
prevDueDate := dateToPtr(prevIssue.DueDate)
dueDateChanged := prevDueDate != resp.DueDate && (prevDueDate == nil) != (resp.DueDate == nil) ||
(prevDueDate != nil && resp.DueDate != nil && *prevDueDate != *resp.DueDate)
@@ -2896,24 +2895,24 @@ func (h *Handler) BatchUpdateIssues(w http.ResponseWriter, r *http.Request) {
}
if _, ok := rawUpdates["start_date"]; ok {
if req.Updates.StartDate != nil && *req.Updates.StartDate != "" {
t, err := time.Parse(time.RFC3339, *req.Updates.StartDate)
d, err := util.ParseCalendarDate(*req.Updates.StartDate)
if err != nil {
continue
}
params.StartDate = pgtype.Timestamptz{Time: t, Valid: true}
params.StartDate = d
} else {
params.StartDate = pgtype.Timestamptz{Valid: false}
params.StartDate = pgtype.Date{Valid: false}
}
}
if _, ok := rawUpdates["due_date"]; ok {
if req.Updates.DueDate != nil && *req.Updates.DueDate != "" {
t, err := time.Parse(time.RFC3339, *req.Updates.DueDate)
d, err := util.ParseCalendarDate(*req.Updates.DueDate)
if err != nil {
continue
}
params.DueDate = pgtype.Timestamptz{Time: t, Valid: true}
params.DueDate = d
} else {
params.DueDate = pgtype.Timestamptz{Valid: false}
params.DueDate = pgtype.Date{Valid: false}
}
}

View File

@@ -185,8 +185,8 @@ func (s *AutopilotService) dispatchCreateIssue(ctx context.Context, ap db.Autopi
CreatorID: leader.ID,
ParentIssueID: pgtype.UUID{},
Position: newPosition,
StartDate: pgtype.Timestamptz{},
DueDate: pgtype.Timestamptz{},
StartDate: pgtype.Date{},
DueDate: pgtype.Date{},
Number: issueNumber,
ProjectID: ap.ProjectID,
OriginType: pgtype.Text{String: "autopilot", Valid: true},

View File

@@ -2036,8 +2036,8 @@ func issueToMap(issue db.Issue, issuePrefix string) map[string]any {
"creator_id": util.UUIDToString(issue.CreatorID),
"parent_issue_id": util.UUIDToPtr(issue.ParentIssueID),
"position": issue.Position,
"start_date": util.TimestampToPtr(issue.StartDate),
"due_date": util.TimestampToPtr(issue.DueDate),
"start_date": util.DateToPtr(issue.StartDate),
"due_date": util.DateToPtr(issue.DueDate),
"created_at": util.TimestampToString(issue.CreatedAt),
"updated_at": util.TimestampToString(issue.UpdatedAt),
}

View File

@@ -92,6 +92,44 @@ func TimestampToPtr(t pgtype.Timestamptz) *string {
return &s
}
// DateToPtr formats a pgtype.Date as a date-only "YYYY-MM-DD" string, or nil
// when unset. Issue start_date/due_date are calendar days with no time-of-day
// or timezone, so they must never be rendered through an instant.
func DateToPtr(d pgtype.Date) *string {
if !d.Valid {
return nil
}
s := d.Time.Format(time.DateOnly)
return &s
}
// ParseCalendarDate parses a calendar day from a "YYYY-MM-DD" string into a
// pgtype.Date carrying no time-of-day or timezone.
//
// For backward compatibility it ALSO accepts an RFC3339 timestamp, but ONLY
// when it lands exactly on a UTC day boundary (e.g. "2026-03-01T00:00:00Z"),
// which unambiguously denotes that calendar day. A non-midnight instant is a
// legacy local-midnight-as-UTC value (e.g. UTC+8 sends "2026-02-28T16:00:00Z"
// for the picked day 2026-03-01) whose intended calendar day is unrecoverable —
// it is rejected loudly rather than silently stored as the wrong day. New
// clients always send "YYYY-MM-DD".
func ParseCalendarDate(s string) (pgtype.Date, error) {
if t, err := time.Parse(time.DateOnly, s); err == nil {
return pgtype.Date{Time: t, Valid: true}, nil
}
if t, err := time.Parse(time.RFC3339, s); err == nil {
u := t.UTC()
if u.Hour() == 0 && u.Minute() == 0 && u.Second() == 0 && u.Nanosecond() == 0 {
return pgtype.Date{
Time: time.Date(u.Year(), u.Month(), u.Day(), 0, 0, 0, 0, time.UTC),
Valid: true,
}, nil
}
return pgtype.Date{}, fmt.Errorf("invalid date %q: timestamps must be a UTC midnight boundary (e.g. 2026-03-01T00:00:00Z); use YYYY-MM-DD", s)
}
return pgtype.Date{}, fmt.Errorf("invalid date %q: expected YYYY-MM-DD", s)
}
func UUIDToPtr(u pgtype.UUID) *string {
if !u.Valid {
return nil

View File

@@ -1,6 +1,11 @@
package util
import "testing"
import (
"testing"
"time"
"github.com/jackc/pgx/v5/pgtype"
)
func TestParseUUID_Valid(t *testing.T) {
u, err := ParseUUID("550e8400-e29b-41d4-a716-446655440000")
@@ -45,3 +50,67 @@ func TestMustParseUUID_RoundTrip(t *testing.T) {
t.Fatalf("round-trip mismatch: got %q want %q", got, s)
}
}
func TestParseCalendarDate_DateOnly(t *testing.T) {
d, err := ParseCalendarDate("2026-03-01")
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
if got := DateToPtr(d); got == nil || *got != "2026-03-01" {
t.Fatalf("round-trip mismatch: got %v want 2026-03-01", got)
}
}
func TestParseCalendarDate_AcceptsUTCMidnight(t *testing.T) {
// A UTC-midnight instant unambiguously denotes that calendar day.
d, err := ParseCalendarDate("2026-03-01T00:00:00Z")
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
if got := DateToPtr(d); got == nil || *got != "2026-03-01" {
t.Fatalf("got %v want 2026-03-01", got)
}
}
func TestParseCalendarDate_RejectsNonMidnightInstant(t *testing.T) {
// The legacy bug: UTC+8 picking 2026-03-01 sent 2026-02-28T16:00:00Z. Its
// intended calendar day is unrecoverable, so reject instead of silently
// storing the wrong day (2026-02-28).
cases := []string{
"2026-02-28T16:00:00Z", // UTC+8 local midnight
"2026-03-01T05:00:00Z", // UTC-5 local midnight
"2026-03-01T00:00:00+08:00",
}
for _, s := range cases {
t.Run(s, func(t *testing.T) {
if _, err := ParseCalendarDate(s); err == nil {
t.Fatalf("expected error for non-midnight instant %q, got nil", s)
}
})
}
}
func TestParseCalendarDate_RejectsGarbage(t *testing.T) {
for _, s := range []string{"", "not-a-date", "03/01/2026", "2026-13-40"} {
t.Run(s, func(t *testing.T) {
if _, err := ParseCalendarDate(s); err == nil {
t.Fatalf("expected error for %q, got nil", s)
}
})
}
}
func TestDateToPtr_NullIsNil(t *testing.T) {
if got := DateToPtr(pgtype.Date{Valid: false}); got != nil {
t.Fatalf("expected nil for invalid date, got %v", *got)
}
}
// Guard against a localtime regression: DateToPtr must emit the stored calendar
// day regardless of the host process timezone.
func TestDateToPtr_StableAcrossTimezone(t *testing.T) {
d := pgtype.Date{Time: time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC), Valid: true}
if got := DateToPtr(d); got == nil || *got != "2026-03-01" {
t.Fatalf("got %v want 2026-03-01", got)
}
}

View File

@@ -0,0 +1,6 @@
-- Revert to TIMESTAMPTZ, interpreting each calendar day as UTC midnight
-- explicitly (independent of the session TimeZone): `date::timestamp` yields
-- midnight with no zone, and `AT TIME ZONE 'UTC'` stamps it as the UTC instant.
ALTER TABLE issue
ALTER COLUMN start_date TYPE TIMESTAMPTZ USING start_date::timestamp AT TIME ZONE 'UTC',
ALTER COLUMN due_date TYPE TIMESTAMPTZ USING due_date::timestamp AT TIME ZONE 'UTC';

View File

@@ -0,0 +1,16 @@
-- Issue start_date / due_date are calendar days: a user picks a day (the
-- pickers have no time-of-day input), so "Mar 1" must mean Mar 1 for everyone
-- regardless of timezone. Storing them as TIMESTAMPTZ folded the writer's
-- local midnight into a UTC instant, shifting the displayed day by the local
-- offset in non-UTC timezones (GH #3618 / MUL-2925). DATE carries no time or
-- timezone, so the picked day is preserved as-is.
--
-- Existing rows are truncated at the UTC day boundary, matching what the Gantt
-- already showed for them. `AT TIME ZONE 'UTC'` pins the conversion to UTC
-- explicitly so it does not depend on the migration session's TimeZone setting
-- (a bare `::date` cast would be session-timezone dependent). The original
-- local-day intent of legacy rows is unrecoverable from a bare instant, so this
-- is the best-effort conversion.
ALTER TABLE issue
ALTER COLUMN start_date TYPE DATE USING (start_date AT TIME ZONE 'UTC')::date,
ALTER COLUMN due_date TYPE DATE USING (due_date AT TIME ZONE 'UTC')::date;

View File

@@ -181,21 +181,21 @@ INSERT INTO issue (
`
type CreateIssueParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Status string `json:"status"`
Priority string `json:"priority"`
AssigneeType pgtype.Text `json:"assignee_type"`
AssigneeID pgtype.UUID `json:"assignee_id"`
CreatorType string `json:"creator_type"`
CreatorID pgtype.UUID `json:"creator_id"`
ParentIssueID pgtype.UUID `json:"parent_issue_id"`
Position float64 `json:"position"`
StartDate pgtype.Timestamptz `json:"start_date"`
DueDate pgtype.Timestamptz `json:"due_date"`
Number int32 `json:"number"`
ProjectID pgtype.UUID `json:"project_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Status string `json:"status"`
Priority string `json:"priority"`
AssigneeType pgtype.Text `json:"assignee_type"`
AssigneeID pgtype.UUID `json:"assignee_id"`
CreatorType string `json:"creator_type"`
CreatorID pgtype.UUID `json:"creator_id"`
ParentIssueID pgtype.UUID `json:"parent_issue_id"`
Position float64 `json:"position"`
StartDate pgtype.Date `json:"start_date"`
DueDate pgtype.Date `json:"due_date"`
Number int32 `json:"number"`
ProjectID pgtype.UUID `json:"project_id"`
}
func (q *Queries) CreateIssue(ctx context.Context, arg CreateIssueParams) (Issue, error) {
@@ -259,23 +259,23 @@ INSERT INTO issue (
`
type CreateIssueWithOriginParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Status string `json:"status"`
Priority string `json:"priority"`
AssigneeType pgtype.Text `json:"assignee_type"`
AssigneeID pgtype.UUID `json:"assignee_id"`
CreatorType string `json:"creator_type"`
CreatorID pgtype.UUID `json:"creator_id"`
ParentIssueID pgtype.UUID `json:"parent_issue_id"`
Position float64 `json:"position"`
StartDate pgtype.Timestamptz `json:"start_date"`
DueDate pgtype.Timestamptz `json:"due_date"`
Number int32 `json:"number"`
ProjectID pgtype.UUID `json:"project_id"`
OriginType pgtype.Text `json:"origin_type"`
OriginID pgtype.UUID `json:"origin_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Status string `json:"status"`
Priority string `json:"priority"`
AssigneeType pgtype.Text `json:"assignee_type"`
AssigneeID pgtype.UUID `json:"assignee_id"`
CreatorType string `json:"creator_type"`
CreatorID pgtype.UUID `json:"creator_id"`
ParentIssueID pgtype.UUID `json:"parent_issue_id"`
Position float64 `json:"position"`
StartDate pgtype.Date `json:"start_date"`
DueDate pgtype.Date `json:"due_date"`
Number int32 `json:"number"`
ProjectID pgtype.UUID `json:"project_id"`
OriginType pgtype.Text `json:"origin_type"`
OriginID pgtype.UUID `json:"origin_id"`
}
func (q *Queries) CreateIssueWithOrigin(ctx context.Context, arg CreateIssueWithOriginParams) (Issue, error) {
@@ -821,8 +821,8 @@ type ListIssuesRow struct {
CreatorID pgtype.UUID `json:"creator_id"`
ParentIssueID pgtype.UUID `json:"parent_issue_id"`
Position float64 `json:"position"`
StartDate pgtype.Timestamptz `json:"start_date"`
DueDate pgtype.Timestamptz `json:"due_date"`
StartDate pgtype.Date `json:"start_date"`
DueDate pgtype.Date `json:"due_date"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
Number int32 `json:"number"`
@@ -961,8 +961,8 @@ type ListOpenIssuesRow struct {
CreatorID pgtype.UUID `json:"creator_id"`
ParentIssueID pgtype.UUID `json:"parent_issue_id"`
Position float64 `json:"position"`
StartDate pgtype.Timestamptz `json:"start_date"`
DueDate pgtype.Timestamptz `json:"due_date"`
StartDate pgtype.Date `json:"start_date"`
DueDate pgtype.Date `json:"due_date"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
Number int32 `json:"number"`
@@ -1138,18 +1138,18 @@ RETURNING id, workspace_id, title, description, status, priority, assignee_type,
`
type UpdateIssueParams struct {
ID pgtype.UUID `json:"id"`
Title pgtype.Text `json:"title"`
Description pgtype.Text `json:"description"`
Status pgtype.Text `json:"status"`
Priority pgtype.Text `json:"priority"`
AssigneeType pgtype.Text `json:"assignee_type"`
AssigneeID pgtype.UUID `json:"assignee_id"`
Position pgtype.Float8 `json:"position"`
StartDate pgtype.Timestamptz `json:"start_date"`
DueDate pgtype.Timestamptz `json:"due_date"`
ParentIssueID pgtype.UUID `json:"parent_issue_id"`
ProjectID pgtype.UUID `json:"project_id"`
ID pgtype.UUID `json:"id"`
Title pgtype.Text `json:"title"`
Description pgtype.Text `json:"description"`
Status pgtype.Text `json:"status"`
Priority pgtype.Text `json:"priority"`
AssigneeType pgtype.Text `json:"assignee_type"`
AssigneeID pgtype.UUID `json:"assignee_id"`
Position pgtype.Float8 `json:"position"`
StartDate pgtype.Date `json:"start_date"`
DueDate pgtype.Date `json:"due_date"`
ParentIssueID pgtype.UUID `json:"parent_issue_id"`
ProjectID pgtype.UUID `json:"project_id"`
}
func (q *Queries) UpdateIssue(ctx context.Context, arg UpdateIssueParams) (Issue, error) {

View File

@@ -347,7 +347,7 @@ type Issue struct {
AcceptanceCriteria []byte `json:"acceptance_criteria"`
ContextRefs []byte `json:"context_refs"`
Position float64 `json:"position"`
DueDate pgtype.Timestamptz `json:"due_date"`
DueDate pgtype.Date `json:"due_date"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
Number int32 `json:"number"`
@@ -355,7 +355,7 @@ type Issue struct {
OriginType pgtype.Text `json:"origin_type"`
OriginID pgtype.UUID `json:"origin_id"`
FirstExecutedAt pgtype.Timestamptz `json:"first_executed_at"`
StartDate pgtype.Timestamptz `json:"start_date"`
StartDate pgtype.Date `json:"start_date"`
Metadata []byte `json:"metadata"`
}