From e5b2e637f519d1e0e99231fcad6b903553e142c8 Mon Sep 17 00:00:00 2001 From: Hiro Date: Thu, 25 Dec 2025 19:11:05 -0600 Subject: [PATCH 1/8] Multi-task management --- .../app/ios/App/App.xcodeproj/project.pbxproj | 8 +- .../menus/TaskContainer/TaskContainer.tsx | 380 +++++++++++++++++- 2 files changed, 376 insertions(+), 12 deletions(-) diff --git a/packages/app/ios/App/App.xcodeproj/project.pbxproj b/packages/app/ios/App/App.xcodeproj/project.pbxproj index d102036..e7ee4ef 100644 --- a/packages/app/ios/App/App.xcodeproj/project.pbxproj +++ b/packages/app/ios/App/App.xcodeproj/project.pbxproj @@ -382,7 +382,7 @@ CODE_SIGN_ENTITLEMENTS = App/App.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = B9UMLF8BH7; ENABLE_USER_SCRIPT_SANDBOXING = NO; INFOPLIST_FILE = App/Info.plist; @@ -393,7 +393,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.0.0; + MARKETING_VERSION = 2.0.1; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; PRODUCT_BUNDLE_IDENTIFIER = "com.ottegi.sequenced-app"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -417,7 +417,7 @@ CODE_SIGN_ENTITLEMENTS = App/App.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = B9UMLF8BH7; ENABLE_USER_SCRIPT_SANDBOXING = NO; INFOPLIST_FILE = App/Info.plist; @@ -428,7 +428,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.0.0; + MARKETING_VERSION = 2.0.1; PRODUCT_BUNDLE_IDENTIFIER = "com.ottegi.sequenced-app"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/packages/app/src/components/menus/TaskContainer/TaskContainer.tsx b/packages/app/src/components/menus/TaskContainer/TaskContainer.tsx index 14bd213..9088bcd 100644 --- a/packages/app/src/components/menus/TaskContainer/TaskContainer.tsx +++ b/packages/app/src/components/menus/TaskContainer/TaskContainer.tsx @@ -10,7 +10,7 @@ import invisible_icon from "@/assets/invisible.svg"; import { ChevronRightIcon } from "@heroicons/react/20/solid"; import { AdjustmentsHorizontalIcon } from "@heroicons/react/24/solid"; -import { Disclosure } from "@headlessui/react"; +import { Disclosure, Menu } from "@headlessui/react"; import { matchDate } from "@/utils/date"; import { isTaskDone, sortByDate, sortByPriority } from "@/utils/data"; import { Task } from "@/hooks/tasks"; @@ -18,6 +18,7 @@ import { useUpdateSettings, useSettings } from "@/hooks/settings"; import { useApp, useAppReducer } from "@/hooks/app"; import { UseQueryResult } from "@tanstack/react-query"; import { useUpdateTask } from "@/hooks/tasks"; +import { EllipsisHorizontalIcon } from "@heroicons/react/24/solid"; interface ContainerSettings { skeleton?: boolean | string; @@ -41,6 +42,11 @@ export default function TaskContainer({ const [selectionMode, setSelectionMode] = useState(false); const [selectedTaskIds, setSelectedTaskIds] = useState([]); const [animatingIds, setAnimatingIds] = useState([]); + const [bulkGroup, setBulkGroup] = useState(""); + const [bulkTag, setBulkTag] = useState(""); + const [workingTags, setWorkingTags] = useState([]); + const [isBulkUpdating, setIsBulkUpdating] = useState(false); + const [bulkAction, setBulkAction] = useState<"" | "group" | "tags">(""); const { mutate: setSettings } = useUpdateSettings(); const settings = useSettings(); const { mutateAsync: updateTask } = useUpdateTask(); @@ -118,11 +124,15 @@ export default function TaskContainer({ const exitSelection = () => { setSelectionMode(false); setSelectedTaskIds([]); + setBulkGroup(""); + setBulkTag(""); + setBulkAction(""); }; const completeSelected = async () => { if (selectedTaskIds.length === 0) return; + setIsBulkUpdating(true); setAnimatingIds(selectedTaskIds); setTimeout(async () => { @@ -143,11 +153,127 @@ export default function TaskContainer({ return { id: task.id, data: { ...task, done: true } }; }); - await Promise.all(updates.map((payload) => updateTask(payload))); + try { + await Promise.all(updates.map((payload) => updateTask(payload))); + exitSelection(); + } finally { + setAnimatingIds([]); + setIsBulkUpdating(false); + } + }, 240); + }; - setAnimatingIds([]); + const normalizeTag = (value: string) => value.trim().toLowerCase(); + const normalizeGroup = (value: string) => value.trim().toLowerCase(); + + const selectedTasks = baseTasks.filter((task) => selectedTaskIds.includes(task.id)); + const uniqueTags = Array.from( + new Set( + selectedTasks.flatMap((task) => + Array.isArray(task.tags) + ? task.tags.map((tag) => + typeof tag === "string" ? normalizeTag(tag) : "" + ) + : [] + ).filter(Boolean) + ) + ); + + const sharedGroup = (() => { + const groups = new Set( + selectedTasks + .map((task) => normalizeGroup(task.group ?? "")) + .filter((g) => g.length > 0) + ); + if (groups.size === 1) return Array.from(groups)[0]; + return ""; + })(); + + const startBulkAction = (action: "" | "group" | "tags") => { + setBulkAction(action); + + if (action === "group") { + setBulkGroup(sharedGroup); + } else if (action === "tags") { + setWorkingTags(uniqueTags); + setBulkTag(""); + } + }; + + const clearBulkAction = () => { + setBulkAction(""); + setBulkGroup(""); + setBulkTag(""); + setWorkingTags([]); + }; + + const bulkUpdateSelected = async (build: (task: Task) => Partial | null | undefined) => { + if (selectedTaskIds.length === 0) return; + setIsBulkUpdating(true); + + const toUpdate = baseTasks.filter((task) => selectedTaskIds.includes(task.id)); + const updates = toUpdate + .map((task) => { + const changes = build(task); + if (!changes) return null; + return updateTask({ id: task.id, data: { id: task.id, ...changes } }); + }) + .filter(Boolean) as Promise[]; + + if (updates.length === 0) { + setIsBulkUpdating(false); + return; + } + + try { + await Promise.all(updates); exitSelection(); - }, 240); + } finally { + setIsBulkUpdating(false); + } + }; + + const addGroupToSelected = async () => { + const groupName = normalizeGroup(bulkGroup); + await bulkUpdateSelected((task) => { + if ((task.group ?? "").toLowerCase() === groupName) return null; + return { group: groupName }; + }); + + setBulkGroup(""); + }; + + const removeGroupFromSelected = async () => { + await bulkUpdateSelected((task) => { + if (!task.group) return null; + return { group: "" }; + }); + }; + + const addTagToSelected = async () => { + const normalized = Array.from( + new Set( + workingTags + .map((tag) => normalizeTag(tag)) + .filter((tag) => tag.length > 0) + ) + ); + + await bulkUpdateSelected((task) => { + const tags = Array.isArray(task.tags) + ? task.tags.map((tag) => (typeof tag === "string" ? normalizeTag(tag) : "")) + : []; + + const existing = Array.from(new Set(tags.filter(Boolean))); + const equal = + existing.length === normalized.length && + existing.every((tag) => normalized.includes(tag)); + + if (equal) return null; + return { tags: normalized }; + }); + + setBulkTag(""); }; const handleClick = async (open: boolean) => { @@ -171,6 +297,201 @@ export default function TaskContainer({ return task; }); + const hasSelection = selectedTaskIds.length > 0; + + const renderBulkActionCard = () => { + if (!selectionMode || !bulkAction) return null; + + const normalizedGroup = normalizeGroup(bulkGroup); + const normalizedTags = Array.from( + new Set(workingTags.map((tag) => normalizeTag(tag)).filter(Boolean)) + ); + + const groupSummary = + sharedGroup && hasSelection + ? `Current group: ${sharedGroup}` + : hasSelection + ? "Group: mixed or none" + : "No selection"; + + const tagSummary = + uniqueTags.length > 0 && hasSelection + ? `Tags: ${uniqueTags.join(", ")}` + : hasSelection + ? "No tags yet" + : "No selection"; + + const tagInputChip = (tag: string) => ( + + #{tag} + + + ); + + return ( +
+
+
+ + {bulkAction === "group" ? "Update group" : "Edit tags"} + + +
+
+ {bulkAction === "group" && ( + <> +
+ {groupSummary} + {hasSelection && ( + Selected: {selectedTaskIds.length} + )} +
+ setBulkGroup(e.target.value)} + placeholder="Set a group or leave blank to clear" + className="w-full rounded-lg border border-accent-blue/20 bg-white px-3 py-2 text-sm text-primary shadow-inner focus:outline-none focus:ring-2 focus:ring-accent-blue/40 dark:bg-[rgba(15,23,42,0.7)]" + /> +
+ + +
+ + Applies the same group to all selected tasks. + + + )} + + {bulkAction === "tags" && ( + <> +
+ {tagSummary} + {hasSelection && ( + Selected: {selectedTaskIds.length} + )} +
+
+ {normalizedTags.length === 0 && ( + No tags yet + )} + {normalizedTags.map(tagInputChip)} + setBulkTag(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === ",") { + e.preventDefault(); + const normalized = normalizeTag(bulkTag); + if (!normalized) return; + setWorkingTags((prev) => + Array.from(new Set([...prev, normalized])) + ); + setBulkTag(""); + } + }} + placeholder="Add tag…" + className="min-w-[140px] flex-1 border-none bg-transparent text-sm text-primary placeholder:text-slate-400 focus:outline-none" + /> +
+
+ {uniqueTags.map((tag) => ( + + ))} +
+
+ + +
+ + Sets this tag list on every selected task. + + + )} +
+
+
+ ); + }; + return (
{/* Migrate to dynamic loading content */} @@ -185,7 +506,7 @@ export default function TaskContainer({ await handleClick(open)} as="div" - className="w-full flex flex-row items-center rounded-2xl bg-white/90 px-3 py-3 text-slate-900 shadow-md ring-1 ring-accent-blue/10 transition hover:-translate-y-0.5 hover:ring-accent-blue/30 [&:has(.task-container-accordian:hover)]:ring-accent-blue/30" + className="w-full flex flex-row items-center rounded-2xl bg-white/90 px-3 py-3 text-slate-900 shadow-md ring-1 ring-accent-blue/10" >
@@ -249,8 +570,8 @@ export default function TaskContainer({ <> + + e.stopPropagation()} + className="flex items-center gap-1 rounded-lg border border-slate-200 bg-white px-2.5 py-1.5 text-xs font-semibold text-slate-700 shadow-sm hover:-translate-y-px transition disabled:opacity-60 disabled:cursor-not-allowed" + > + + Actions + + + {[ + { key: "group", label: "Edit group" }, + { key: "tags", label: "Edit tags" }, + ].map((item) => ( + + {({ active }) => ( + + )} + + ))} + +
+ {renderBulkActionCard()} {/* {!settings.data.groupsActive?.includes(identifier) && ( */} Date: Thu, 25 Dec 2025 19:14:57 -0600 Subject: [PATCH 2/8] Color hover effect replacement --- .../menus/TaskContainer/TaskContainer.tsx | 16 ++++++++-------- .../app/src/components/task/TaskItemShell.tsx | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/app/src/components/menus/TaskContainer/TaskContainer.tsx b/packages/app/src/components/menus/TaskContainer/TaskContainer.tsx index 9088bcd..639b571 100644 --- a/packages/app/src/components/menus/TaskContainer/TaskContainer.tsx +++ b/packages/app/src/components/menus/TaskContainer/TaskContainer.tsx @@ -374,7 +374,7 @@ export default function TaskContainer({
+ ); + })} + {reviewRating} / 5 +
+