diff --git a/packages/api/src/review/review.controller.ts b/packages/api/src/review/review.controller.ts new file mode 100644 index 0000000..c295fd1 --- /dev/null +++ b/packages/api/src/review/review.controller.ts @@ -0,0 +1,49 @@ +import { Controller, Get, Inject, Middleware, Post } from "@outwalk/firefly"; +import { Request } from "express"; +import { ReviewService } from "./review.service"; +import { session } from "@/_middleware/session"; +import sendToWebhook from "@/logging/webhook"; +import { BadRequest } from "@outwalk/firefly/errors"; + +@Controller() +@Middleware(session) +export class ReviewController { + + @Inject() + reviewService: ReviewService; + + @Post() + async createReview({ body, session }: Request) { + const rating = Number(body?.rating); + if (!Number.isFinite(rating) || rating < 1 || rating > 5) { + throw new BadRequest("Rating must be between 1 and 5."); + } + + const message = typeof body?.message === "string" ? body.message.trim() : ""; + const review = await this.reviewService.createReview({ + rating, + message, + userId: session?.user?.id + }); + + await sendToWebhook({ + embeds: [ + { + title: "New Review Submitted", + description: `Rating: **${rating}/5**${message ? `\\nMessage: ${message}` : ""}`, + footer: { + text: review.userEmail ? `From ${review.userEmail}` : "From an authenticated user" + }, + timestamp: new Date() + } + ] + }); + + return { success: true }; + } + + @Get() + async listReviews() { + return this.reviewService.listReviews(); + } +} diff --git a/packages/api/src/review/review.entity.ts b/packages/api/src/review/review.entity.ts new file mode 100644 index 0000000..7b4262f --- /dev/null +++ b/packages/api/src/review/review.entity.ts @@ -0,0 +1,20 @@ +import { Entity, Model, Prop } from "@/_lib/mongoose"; +import mongoose from "mongoose"; +import { User } from "@/user/user.entity"; + +@Entity({ timestamps: true }) +export class Review extends Model { + id: string; + + @Prop({ type: Number, required: true, min: 1, max: 5 }) + rating: number; + + @Prop({ type: String, default: "" }) + message: string; + + @Prop({ type: mongoose.Schema.Types.ObjectId, ref: "User", required: false }) + user?: User | string; + + @Prop({ type: String, default: "" }) + userEmail?: string; +} diff --git a/packages/api/src/review/review.service.ts b/packages/api/src/review/review.service.ts new file mode 100644 index 0000000..e24674a --- /dev/null +++ b/packages/api/src/review/review.service.ts @@ -0,0 +1,29 @@ +import { Injectable, Inject } from "@outwalk/firefly"; +import { Review } from "./review.entity"; +import { UserService } from "@/user/user.service"; + +@Injectable() +export class ReviewService { + @Inject() + userService: UserService; + + async createReview({ + rating, + message, + userId + }: { rating: number; message?: string; userId?: string }) { + const doc: Partial = { rating, message: message ?? "" }; + + if (userId) { + doc.user = userId; + const user = await this.userService.getUserById(userId); + if (user?.email) doc.userEmail = user.email; + } + + return Review.create(doc); + } + + async listReviews(): Promise { + return Review.find().sort({ createdAt: -1 }).lean().exec(); + } +} 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/assets/social_icons/discord.svg b/packages/app/src/assets/social_icons/discord.svg new file mode 100644 index 0000000..b636d15 --- /dev/null +++ b/packages/app/src/assets/social_icons/discord.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/app/src/components/calendar/ActiveCalendar.tsx b/packages/app/src/components/calendar/ActiveCalendar.tsx index b645591..fcac099 100644 --- a/packages/app/src/components/calendar/ActiveCalendar.tsx +++ b/packages/app/src/components/calendar/ActiveCalendar.tsx @@ -118,13 +118,13 @@ export default function ActiveCalendar({ skeleton }: ActiveCalendarProps) { This Week -
-
+
+
diff --git a/packages/app/src/components/menus/TaskContainer/TaskContainer.tsx b/packages/app/src/components/menus/TaskContainer/TaskContainer.tsx index 14bd213..61f9c8b 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, Transition } 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, CheckCircleIcon } from "@heroicons/react/24/solid"; interface ContainerSettings { skeleton?: boolean | string; @@ -41,6 +42,12 @@ 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 [completionToast, setCompletionToast] = useState(null); const { mutate: setSettings } = useUpdateSettings(); const settings = useSettings(); const { mutateAsync: updateTask } = useUpdateTask(); @@ -118,11 +125,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,13 +154,135 @@ export default function TaskContainer({ return { id: task.id, data: { ...task, done: true } }; }); + try { await Promise.all(updates.map((payload) => updateTask(payload))); - - setAnimatingIds([]); + showCompletionToast(`Marked ${selectedTaskIds.length} task${selectedTaskIds.length === 1 ? "" : "s"} complete`); exitSelection(); + } finally { + setAnimatingIds([]); + setIsBulkUpdating(false); + } }, 240); }; + 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 showCompletionToast = (text: string) => { + setCompletionToast(text); + setTimeout(() => setCompletionToast(null), 2200); + }; + + 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(); + } 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) => { let groupsActive = settings.data?.groupsActive; @@ -171,6 +304,207 @@ 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 +519,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" >
@@ -236,7 +570,7 @@ export default function TaskContainer({ {!selectionMode && ( + + 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 transition hover:shadow-md hover:ring-1 hover:ring-slate-200 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) && ( */} showCompletionToast(`Task completed: ${task.title || "Untitled"}`)} /> {/* )} */} @@ -292,6 +670,27 @@ export default function TaskContainer({ )} )} + +
+
+ + {completionToast} +
+
+
); } diff --git a/packages/app/src/components/task/TaskItem.tsx b/packages/app/src/components/task/TaskItem.tsx index b0191b9..909ca99 100644 --- a/packages/app/src/components/task/TaskItem.tsx +++ b/packages/app/src/components/task/TaskItem.tsx @@ -18,9 +18,10 @@ interface TaskItemParams { isSelected?: boolean; onToggleSelect?: (id: string) => void; isAnimating?: boolean; + onComplete?: (task: Task) => void; } -export function TaskItem({ skeleton, item, setIsInspecting, taskFilter, selectionMode = false, isSelected = false, onToggleSelect, isAnimating = false }: TaskItemParams) { +export function TaskItem({ skeleton, item, setIsInspecting, taskFilter, selectionMode = false, isSelected = false, onToggleSelect, isAnimating = false, onComplete }: TaskItemParams) { if (skeleton) { return (
@@ -102,6 +103,8 @@ export function TaskItem({ skeleton, item, setIsInspecting, taskFilter, selectio updateTask({ id: item.id, data: { ...item, done: !item.done } }); } + if (onComplete) onComplete(item as Task); + // allow fade-out before hiding when filtering incomplete setTimeout(() => setIsCompleting(false), 600); }; diff --git a/packages/app/src/components/task/TaskItemShell.tsx b/packages/app/src/components/task/TaskItemShell.tsx index da8ff57..a5b9341 100644 --- a/packages/app/src/components/task/TaskItemShell.tsx +++ b/packages/app/src/components/task/TaskItemShell.tsx @@ -8,7 +8,7 @@ interface ShellParams { export default function TaskItemShell({ skeleton, children, task, activeDate, className = "", ...props }: ShellParams) { if (skeleton) { return ( -
+
{children}
) @@ -20,7 +20,7 @@ export default function TaskItemShell({ skeleton, children, task, activeDate, cl
{children}
diff --git a/packages/app/src/components/tasks/TaskMenu.tsx b/packages/app/src/components/tasks/TaskMenu.tsx index f87484b..b6112fe 100644 --- a/packages/app/src/components/tasks/TaskMenu.tsx +++ b/packages/app/src/components/tasks/TaskMenu.tsx @@ -12,7 +12,8 @@ export default function TaskMenu({ selectedTaskIds = [], toggleSelection, animatingIds = [], - activeDate + activeDate, + onTaskComplete }) { const [collapsedGroups, setCollapsedGroups] = useState([]); const [orderedTasks, setOrderedTasks] = useState([]); @@ -81,6 +82,7 @@ export default function TaskMenu({ isSelected={selectedTaskIds.includes(task.id)} onToggleSelect={toggleSelection} isAnimating={animatingIds.includes(task.id)} + onComplete={onTaskComplete} />
); diff --git a/packages/app/src/pages/(Layout)/(TaskInfoMenu)/Shared/TaskInfoMenuItem.tsx b/packages/app/src/pages/(Layout)/(TaskInfoMenu)/Shared/TaskInfoMenuItem.tsx index 483db07..3ca1087 100644 --- a/packages/app/src/pages/(Layout)/(TaskInfoMenu)/Shared/TaskInfoMenuItem.tsx +++ b/packages/app/src/pages/(Layout)/(TaskInfoMenu)/Shared/TaskInfoMenuItem.tsx @@ -40,6 +40,19 @@ export default function TaskInfoMenuItem({ autoFocus={false} > ); + } else if (type === "datetime-local") { + inputPiece = ( + + ); } return ( diff --git a/packages/app/src/pages/Auth/LoginHome.tsx b/packages/app/src/pages/Auth/LoginHome.tsx index bb7263b..f8333b8 100644 --- a/packages/app/src/pages/Auth/LoginHome.tsx +++ b/packages/app/src/pages/Auth/LoginHome.tsx @@ -8,7 +8,7 @@ export default function LoginHome() { return (
diff --git a/packages/app/src/pages/Auth/LoginUser.tsx b/packages/app/src/pages/Auth/LoginUser.tsx index 1aab53b..7a3dd6d 100644 --- a/packages/app/src/pages/Auth/LoginUser.tsx +++ b/packages/app/src/pages/Auth/LoginUser.tsx @@ -36,7 +36,7 @@ export default function LoginUser() { return (
diff --git a/packages/app/src/pages/Auth/RegisterUser.tsx b/packages/app/src/pages/Auth/RegisterUser.tsx index 142e89e..bff1d00 100644 --- a/packages/app/src/pages/Auth/RegisterUser.tsx +++ b/packages/app/src/pages/Auth/RegisterUser.tsx @@ -39,7 +39,7 @@ export default function RegisterUser() { return (
diff --git a/packages/app/src/pages/Settings.tsx b/packages/app/src/pages/Settings.tsx index a1627f0..054b2fe 100644 --- a/packages/app/src/pages/Settings.tsx +++ b/packages/app/src/pages/Settings.tsx @@ -4,8 +4,6 @@ import { setDailyReminders, scheduleNotification, } from "@/utils/notifs"; -import { Capacitor } from "@capacitor/core"; - import { Settings, getSettings, setSettings } from "@/hooks/settings"; import { PendingLocalNotificationSchema } from "@capacitor/local-notifications"; import { useEffect, useState, FormEvent } from "react"; @@ -18,10 +16,12 @@ import { useTasks, useDeleteTask } from "@/hooks/tasks"; import xIcon from "@/assets/social_icons/x.svg"; import instagramIcon from "@/assets/social_icons/instagram.svg"; import facebookIcon from "@/assets/social_icons/facebook.svg"; +import discordIcon from "@/assets/social_icons/discord.svg"; import { useApp } from "@/hooks/app"; import { useChangePassword, useExportUserData, useRequestUserDeletion, useUpdateProfile, useUser } from "@/hooks/user"; import { getTodayNotificationBody } from "@/utils/notifs"; import { fetchData } from "@/utils/data"; +import { StarIcon } from "@heroicons/react/24/solid"; export default function SettingsPage() { const [tempSettings, setTempSettings] = useState({}); @@ -43,6 +43,9 @@ export default function SettingsPage() { const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [deleteInput, setDeleteInput] = useState(""); const [showChangePassword, setShowChangePassword] = useState(false); + const [reviewRating, setReviewRating] = useState(5); + const [reviewMessage, setReviewMessage] = useState(""); + const [reviewStatus, setReviewStatus] = useState(""); useEffect(() => { getSettings().then(async (tempSettings) => { @@ -244,22 +247,24 @@ export default function SettingsPage() { Logger.log("Scheduled test notification for", fireAt.toISOString()); }; - const openStoreReview = () => { - const APP_STORE_ID = "6478198104"; - const PLAY_STORE_ID = "com.ottegi.sequenced"; - const platform = Capacitor.getPlatform(); - - if (platform === "ios") { - window.open(`itms-apps://apps.apple.com/app/id${APP_STORE_ID}?action=write-review`, "_system"); + const submitReview = async (e: FormEvent) => { + e.preventDefault(); + setReviewStatus(""); + if (reviewRating < 1 || reviewRating > 5) { + setReviewStatus("Please pick a rating between 1 and 5 stars."); return; } - - if (platform === "android") { - window.open(`market://details?id=${PLAY_STORE_ID}`, "_system"); - return; + try { + await fetchData("/review", { + method: "POST", + body: { rating: reviewRating, message: reviewMessage.trim() } + }); + setReviewStatus("Thanks for your feedback!"); + setReviewMessage(""); + setReviewRating(5); + } catch (err: any) { + setReviewStatus(err?.message || "Unable to submit review right now."); } - - window.open(`https://apps.apple.com/app/id${APP_STORE_ID}?action=write-review`, "_blank"); }; return ( @@ -553,30 +558,20 @@ export default function SettingsPage() {
- -

Follow Ottegi

Stay up to date with releases and progress.