Ctrl K
ComunidadGuías…

src/components/tasks/task-panel.tsx

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"use client";

import { useMemo, useState, useTransition } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { AnimatePresence, motion } from "framer-motion";
import { PlusIcon, Trash2Icon } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
  createTaskAction,
  deleteTaskAction,
  toggleTaskAction,
  updateTaskAction,
} from "@/server/actions";
import { cn, relativeDay } from "@/lib/utils";
import { daysBetween } from "@/lib/time";
import { useViewerZone } from "@/components/viewer-zone";
import { DateField } from "@/components/ui/date-field";
import {
  SubjectPicker,
  subjectColumns,
  type SubjectOption,
} from "@/components/tasks/subject-picker";
import { SUBJECT_LABEL, type TaskSubjectKind, type TaskSubjectView } from "@/lib/task-subject";

export type TaskRow = {
  id: string;
  title: string;
  detail: string;
  /** Full ISO, for reading a date off; empty when it has no due date. */
  dueISO: string;
  /** yyyy-mm-dd, what a date input wants. Empty when undated. */
  dueDate: string;
  done: boolean;
  /** Whatever this one is about, already resolved. Null for a loose task. */
  subject: TaskSubjectView | null;
};

/** The buckets, in the order a person works down them. */
const BUCKETS = [
  { key: "overdue", label: "Overdue" },
  { key: "today", label: "Today" },
  { key: "week", label: "Next 7 days" },
  { key: "later", label: "Later" },
  { key: "undated", label: "No date" },
] as const;

type BucketKey = (typeof BUCKETS)[number]["key"];

/**
 * Which pile a task belongs in, counted in calendar days on the reader's
 * calendar. Reading the host's parts instead put a task due tonight in
 * Overdue for anyone west of the server — and, because this renders on both
 * sides of a hydration, could put it in two different piles at once.
 */
function bucketOf(dueISO: string, timeZone: string): BucketKey {
  if (!dueISO) return "undated";
  const days = daysBetween(new Date(), new Date(dueISO), timeZone);
  if (days < 0) return "overdue";
  if (days === 0) return "today";
  if (days <= 7) return "week";
  return "later";
}

/**
 * Everything you owe yourself, as one worked list.
 *
 * The bell in the top bar is the glance — what has come round, five words
 * each. This is the front page, where you actually mean to clear the list, so
 * a task here can be reworded, re-dated, hooked to a role and deleted — none
 * of which was possible anywhere in the app before, tasks being write-once and
 * tick-once.
 *
 * Grouped by when rather than by what: an overdue thing and a Thursday thing
 * are different problems, and a flat list sorted by date makes you work that
 * out for yourself every time you look.
 */
export function TaskPanel({
  tasks,
  subjects,
}: {
  tasks: TaskRow[];
  /** Everything a task can be hung on, across all six kinds. */
  subjects: SubjectOption[];
}) {
  const zone = useViewerZone();
  const router = useRouter();
  const [pending, startTransition] = useTransition();
  const [draft, setDraft] = useState("");
  const [draftDue, setDraftDue] = useState("");
  const [draftDetail, setDraftDetail] = useState("");
  const [draftSubject, setDraftSubject] = useState<{ kind: TaskSubjectKind; id: string } | null>(
    null,
  );
  const [showDone, setShowDone] = useState(false);

  const open = useMemo(() => tasks.filter((task) => !task.done), [tasks]);
  const done = useMemo(() => tasks.filter((task) => task.done), [tasks]);

  const grouped = useMemo(() => {
    const map = new Map<BucketKey, TaskRow[]>();
    for (const task of open) {
      const key = bucketOf(task.dueISO, zone);
      map.set(key, [...(map.get(key) ?? []), task]);
    }
    // Soonest first inside a bucket; undated keeps the order it arrived in,
    // which is newest-created first.
    for (const [key, rows] of map) {
      if (key !== "undated") rows.sort((a, b) => a.dueISO.localeCompare(b.dueISO));
    }
    return map;
  }, [open, zone]);

  const act = (work: () => Promise<unknown>, message?: string) => {
    startTransition(async () => {
      try {
        await work();
        if (message) toast.success(message);
        router.refresh();
      } catch (error) {
        toast.error(error instanceof Error ? error.message : "Could not do that.");
      }
    });
  };

  const add = () => {
    const title = draft.trim();
    if (!title) return;
    const dueAt = draftDue;
    const detail = draftDetail.trim();
    const subject = draftSubject;
    // Cleared before the write, not after: a second task typed while the first
    // is still in flight should start from an empty form.
    setDraft("");
    setDraftDue("");
    setDraftDetail("");
    setDraftSubject(null);
    act(() =>
      createTaskAction({
        title,
        detail,
        dueAt: dueAt || null,
        ...subjectColumns(subject),
      }),
    );
  };

  return (
    <div className="space-y-4">
      <div className="bg-card shadow-card space-y-2 rounded-xl p-3">
        <div className="relative">
          <PlusIcon className="text-faint pointer-events-none absolute top-1/2 left-3 size-3.5 -translate-y-1/2" />
          <Input
            value={draft}
            onChange={(event) => setDraft(event.target.value)}
            onKeyDown={(event) => {
              if (event.key === "Enter") add();
            }}
            placeholder="Send the follow-up email, ask Priya for the intro…"
            className="h-11 pl-8 text-base md:h-9 md:text-[13px]"
            disabled={pending}
          />
        </div>
        {/* The detail only appears once there is a task to detail. An empty
            second box above an empty first box is a form; one line is a
            thing you type into. */}
        {draft.trim() && (
          <Textarea
            value={draftDetail}
            onChange={(event) => setDraftDetail(event.target.value)}
            placeholder="What it involves, who said it, what to reference…"
            className="min-h-16 text-[13px]"
            disabled={pending}
          />
        )}
        <div className="flex flex-wrap items-center gap-2">
          <DateField
            value={draftDue}
            onChange={setDraftDue}
            ariaLabel="Due date"
            placeholder="No due date"
            className="w-48"
          />
          <SubjectPicker
            value={draftSubject}
            options={subjects}
            onChange={setDraftSubject}
            className="w-56"
          />
          <Button size="sm" onClick={add} disabled={pending || !draft.trim()} className="ml-auto">
            Add task
          </Button>
        </div>
      </div>

      {open.length === 0 ? (
        <div className="bg-card shadow-card rounded-xl py-10 text-center">
          <p className="text-[13px] font-medium">Nothing on the list</p>
          <p className="text-muted-foreground mt-1 text-[13px]">
            Add one above, or ask your assistant to — it can see this list too.
          </p>
        </div>
      ) : (
        BUCKETS.map(({ key, label }) => {
          const rows = grouped.get(key) ?? [];
          if (rows.length === 0) return null;
          return (
            <section key={key} className="bg-card shadow-card overflow-hidden rounded-xl">
              <div className="eyebrow bg-inset flex items-center gap-2 px-4 py-2">
                <span className={cn(key === "overdue" && "text-destructive")}>{label}</span>
                <span className="text-faint nums">{rows.length}</span>
              </div>
              <ul className="divide-y">
                <AnimatePresence initial={false}>
                  {rows.map((task) => (
                    <TaskItem
                      key={task.id}
                      task={task}
                      subjects={subjects}
                      pending={pending}
                      act={act}
                    />
                  ))}
                </AnimatePresence>
              </ul>
            </section>
          );
        })
      )}

      {done.length > 0 && (
        <div className="space-y-2">
          <Button
            variant="ghost"
            size="xs"
            className="text-muted-foreground"
            onClick={() => setShowDone((value) => !value)}
          >
            {showDone ? "Hide" : "Show"} {done.length} done
          </Button>
          {showDone && (
            <ul className="bg-card shadow-card divide-y overflow-hidden rounded-xl">
              {done.map((task) => (
                <li key={task.id} className="flex items-center gap-2.5 px-4 py-2">
                  <Checkbox
                    checked
                    aria-label={`Reopen ${task.title}`}
                    disabled={pending}
                    onCheckedChange={() => act(() => toggleTaskAction(task.id, false), "Reopened")}
                  />
                  <span className="text-muted-foreground min-w-0 flex-1 truncate text-[13px] line-through">
                    {task.title}
                  </span>
                  <Button
                    variant="ghost"
                    size="icon-sm"
                    aria-label={`Delete ${task.title}`}
                    className="text-faint hover:text-destructive"
                    disabled={pending}
                    onClick={() => act(() => deleteTaskAction(task.id))}
                  >
                    <Trash2Icon />
                  </Button>
                </li>
              ))}
            </ul>
          )}
        </div>
      )}
    </div>
  );
}

function TaskItem({
  task,
  subjects,
  pending,
  act,
}: {
  task: TaskRow;
  subjects: SubjectOption[];
  pending: boolean;
  act: (work: () => Promise<unknown>, message?: string) => void;
}) {
  const zone = useViewerZone();
  const [title, setTitle] = useState(task.title);
  const [editing, setEditing] = useState(false);
  const overdue = bucketOf(task.dueISO, zone) === "overdue";

  const rename = () => {
    setEditing(false);
    const next = title.trim();
    if (!next || next === task.title) {
      setTitle(task.title);
      return;
    }
    act(() => updateTaskAction(task.id, { title: next }));
  };

  return (
    <motion.li
      layout
      exit={{ opacity: 0, height: 0 }}
      transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
      className="group flex flex-wrap items-center gap-2.5 px-4 py-2.5"
    >
      <Checkbox
        aria-label={`Complete ${task.title}`}
        disabled={pending}
        onCheckedChange={() => act(() => toggleTaskAction(task.id, true), "Done")}
      />

      <div className="min-w-0 flex-1">
        {editing ? (
          <Input
            value={title}
            autoFocus
            onChange={(event) => setTitle(event.target.value)}
            onBlur={rename}
            onKeyDown={(event) => {
              if (event.key === "Enter") rename();
              if (event.key === "Escape") {
                setTitle(task.title);
                setEditing(false);
              }
            }}
            className="h-8 text-[13px]"
          />
        ) : (
          <button
            type="button"
            onClick={() => setEditing(true)}
            className="block w-full truncate text-left text-[13px]"
            title="Click to rename"
          >
            {task.title}
          </button>
        )}
        {task.detail && (
          <p className="text-faint mt-0.5 line-clamp-2 text-[12px]">{task.detail}</p>
        )}
        {task.subject && (
          <Link
            href={task.subject.href}
            className="text-faint hover:text-foreground mt-0.5 flex items-center gap-1 text-[11.5px] transition-colors"
          >
            {SUBJECT_LABEL[task.subject.kind]} · {task.subject.label}
          </Link>
        )}
      </div>

      <span
        className={cn(
          "nums hidden w-24 text-right text-[12px] sm:block",
          overdue ? "text-destructive font-medium" : "text-faint",
        )}
      >
        {task.dueISO ? relativeDay(new Date(task.dueISO), zone) : ""}
      </span>

      {/* The date and the role are edits, not decoration: a task that has
          slipped gets moved rather than re-typed. */}
      <div className="flex items-center gap-1">
        <DateField
          value={task.dueDate}
          onChange={(dueAt) => act(() => updateTaskAction(task.id, { dueAt: dueAt || null }))}
          ariaLabel={`Due date for ${task.title}`}
          placeholder="No due date"
          className="w-44"
        />
        <SubjectPicker
          size="sm"
          className="w-40"
          value={task.subject ? { kind: task.subject.kind, id: task.subject.id } : null}
          options={subjects}
          onChange={(next) => act(() => updateTaskAction(task.id, subjectColumns(next)))}
        />
        <Button
          variant="ghost"
          size="icon-sm"
          aria-label={`Delete ${task.title}`}
          className="text-faint hover:text-destructive opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
          disabled={pending}
          onClick={() => act(() => deleteTaskAction(task.id))}
        >
          <Trash2Icon />
        </Button>
      </div>
    </motion.li>
  );
}