Trips

index.jsx 476 lines

Files · 9
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476import React, { useEffect, useState } from "react";
import { createRoot } from "react-dom/client";
import {
  Page,
  Header,
  Button,
  Input,
  Modal,
  EmptyState,
  Row,
} from "../../shared/ui.jsx";
import { useModel } from "../../shared/data.js";
import { Select, ErrorNotice } from "../../shared/ui.jsx";
import { FormActions } from "../../shared/forms.jsx";
import { createModel } from "./model.js";
import { localDate, daysBetween } from "./runtime.js";
import "./style.css";
const model = createModel(window.lattice);
const kindNames = {
  flight: "Flight",
  lodging: "Lodging",
  activity: "Activity",
};
function TripForm({ busy, onClose, onSave }) {
  const [form, setForm] = useState({
    title: "",
    destination: "",
    start_date: localDate(),
    end_date: localDate(),
  });
  const set = (k, v) => setForm((f) => ({ ...f, [k]: v }));
  return (
    <Modal title="New trip" onClose={onClose}>
      <form
        onSubmit={(e) => {
          e.preventDefault();
          onSave(form);
        }}
      >
        <Input
          label="Trip name"
          required
          value={form.title}
          onChange={(e) => set("title", e.target.value)}
          placeholder="e.g. A few days in Lisbon"
        />
        <Input
          label="Destination"
          required
          value={form.destination}
          onChange={(e) => set("destination", e.target.value)}
          placeholder="City, country"
        />
        <div className="ui-form-grid">
          <Input
            label="Start date"
            type="date"
            required
            value={form.start_date}
            onChange={(e) => {
              const v = e.target.value;
              setForm((f) => ({
                ...f,
                start_date: v,
                end_date: f.end_date < v ? v : f.end_date,
              }));
            }}
          />
          <Input
            label="End date"
            type="date"
            min={form.start_date}
            required
            value={form.end_date}
            onChange={(e) => set("end_date", e.target.value)}
          />
        </div>
        <FormActions busy={busy} onCancel={onClose} label="Create trip" />
      </form>
    </Modal>
  );
}
function ItemForm({ trip, busy, onClose, onSave }) {
  const [form, setForm] = useState({
    trip_id: trip.id,
    kind: "activity",
    title: "",
    date: trip.start_date,
    time: "",
    location: "",
    confirmation_code: "",
  });
  const set = (k, v) => setForm((f) => ({ ...f, [k]: v }));
  return (
    <Modal title="Add itinerary item" onClose={onClose}>
      <form
        onSubmit={(e) => {
          e.preventDefault();
          onSave(form);
        }}
      >
        <Input
          label="Title"
          required
          value={form.title}
          onChange={(e) => set("title", e.target.value)}
        />
        <Select
          label="Type"
          value={form.kind}
          onChange={(e) => set("kind", e.target.value)}
        >
          {Object.entries(kindNames).map(([k, n]) => (
            <option key={k} value={k}>
              {n}
            </option>
          ))}
        </Select>
        <div className="ui-form-grid">
          <Input
            label="Date"
            type="date"
            required
            min={trip.start_date}
            max={trip.end_date}
            value={form.date}
            onChange={(e) => set("date", e.target.value)}
          />
          <Input
            label="Time (optional)"
            type="time"
            value={form.time}
            onChange={(e) => set("time", e.target.value)}
          />
        </div>
        <Input
          label="Location (optional)"
          value={form.location}
          onChange={(e) => set("location", e.target.value)}
        />
        <Input
          label="Confirmation code (optional)"
          value={form.confirmation_code}
          onChange={(e) => set("confirmation_code", e.target.value)}
        />
        <p className="ui-muted">
          Dates and times are local to the destination.
        </p>
        <FormActions busy={busy} onCancel={onClose} label="Add item" />
      </form>
    </Modal>
  );
}
function PackingForm({ trip, busy, onClose, onSave }) {
  const [label, setLabel] = useState(""),
    [quantity, setQuantity] = useState(1);
  return (
    <Modal title="Add packing item" onClose={onClose}>
      <form
        onSubmit={(e) => {
          e.preventDefault();
          onSave({ trip_id: trip.id, label, quantity });
        }}
      >
        <Input
          label="Item name"
          required
          value={label}
          onChange={(e) => setLabel(e.target.value)}
          placeholder="e.g. Phone charger"
        />
        <Input
          label="Quantity"
          type="number"
          min="1"
          max="999"
          required
          value={quantity}
          onChange={(e) => setQuantity(Number(e.target.value))}
        />
        <FormActions busy={busy} onCancel={onClose} label="Add to checklist" />
      </form>
    </Modal>
  );
}
function App() {
  const { data, error, busy, act } = useModel(model),
    [selected, setSelected] = useState(null),
    [dialog, setDialog] = useState(""),
    [removing, setRemoving] = useState(null);
  useEffect(() => {
    const timer = setInterval(() => act(() => model.tick()), 60000);
    return () => clearInterval(timer);
  }, []);
  const trips = data?.trips ?? [],
    trip =
      trips.find((t) => t.id === selected) ??
      trips.find((t) => t.end_date >= localDate()) ??
      trips[0],
    items = (data?.items ?? []).filter((i) => i.trip_id === trip?.id),
    packing = (data?.packing ?? []).filter((i) => i.trip_id === trip?.id),
    packed = packing.filter((i) => i.checked).length;
  const close = () => !busy && setDialog("");
  return (
    <Page className="new-app trips">
      <Header
        title="Trips"
        subtitle="Plans, bookings, and everything to bring."
        actions={
          <>
            <Button variant="primary" onClick={() => setDialog("trip")}>
              Add trip
            </Button>
          </>
        }
      />
      <ErrorNotice error={error} pending={data?.pending} />
      {!data ? (
        <EmptyState title="Loading trips…" />
      ) : !trips.length ? (
        <div className="ui-panel">
          <EmptyState
            title="No trips yet"
            action={
              <div className="ui-actions">
                <Button onClick={() => setDialog("trip")}>Plan a trip</Button>
                <Button
                  disabled={busy}
                  onClick={() => act(() => model.sample())}
                >
                  Add sample data
                </Button>
              </div>
            }
          >
            Keep an itinerary and packing list together.
          </EmptyState>
        </div>
      ) : (
        <div className="ui-master-detail">
          <aside>
            <p className="ui-caption" style={{ marginBottom: 12 }}>
              Your trips · {trips.length}
            </p>
            <div className="ui-panel">
              {trips.map((t) => (
                <button
                  key={t.id}
                  className="trip-choice"
                  aria-pressed={t.id === trip.id}
                  onClick={() => setSelected(t.id)}
                >
                  <strong>{t.title}</strong>
                  <div className="ui-muted">{t.destination}</div>
                  <div className="ui-muted" style={{ marginTop: 8 }}>
                    {t.start_date} → {t.end_date}
                  </div>
                </button>
              ))}
            </div>
          </aside>
          <section aria-label="Trip details">
            <div className="trip-summary">
              <div>
                <p className="ui-caption">{trip.destination}</p>
                <h2 style={{ margin: "6px 0 8px" }}>{trip.title}</h2>
                <p className="ui-muted">
                  {trip.start_date} → {trip.end_date} ·{" "}
                  {daysBetween(trip.start_date, trip.end_date) + 1} days
                </p>
              </div>
              <span className="ui-badge ui-badge-accent">
                {trip.start_date > localDate()
                  ? `In ${daysBetween(localDate(), trip.start_date)} days`
                  : trip.end_date < localDate()
                    ? "Past trip"
                    : "Underway"}
              </span>
            </div>
            <div className="ui-list-header">
              <h2>Itinerary</h2>
              <Button onClick={() => setDialog("item")}>+ Add item</Button>
            </div>
            <div className="ui-panel">
              {!items.length ? (
                <EmptyState title="No itinerary items">
                  Add a flight, a place to stay, or an activity.
                </EmptyState>
              ) : (
                items.map((item, index) => (
                  <React.Fragment key={item.id}>
                    {(index === 0 || items[index - 1].date !== item.date) && (
                      <div className="timeline-date">
                        {new Date(item.date + "T12:00:00").toLocaleDateString(
                          undefined,
                          { weekday: "long", month: "short", day: "numeric" },
                        )}
                      </div>
                    )}
                    <Row
                      leading={
                        <span className="timeline-time">
                          {item.time || "Any time"}
                        </span>
                      }
                      title={item.title}
                      subtitle={item.location}
                      actions={
                        <Button
                          variant="ghost"
                          aria-label={`Remove ${item.title}`}
                          onClick={() =>
                            setRemoving({
                              type: "item",
                              id: item.id,
                              label: item.title,
                            })
                          }
                        >
                          ×
                        </Button>
                      }
                    >
                      <div className="ui-meta">
                        <span className="ui-badge">{kindNames[item.kind]}</span>
                        {item.confirmation_code && (
                          <span className="ui-muted">
                            Ref. {item.confirmation_code}
                          </span>
                        )}
                      </div>
                    </Row>
                  </React.Fragment>
                ))
              )}
            </div>
            <div className="ui-list-header">
              <h2>
                Packing{" "}
                <span className="ui-muted">
                  {packed} / {packing.length}
                </span>
              </h2>
              <Button onClick={() => setDialog("packing")}>+ Add item</Button>
            </div>
            <div
              className="progress"
              role="progressbar"
              aria-label="Packing progress"
              aria-valuemin={0}
              aria-valuemax={packing.length || 1}
              aria-valuenow={packed}
            >
              <span
                style={{
                  width: `${packing.length ? (packed / packing.length) * 100 : 0}%`,
                }}
              />
            </div>
            <div className="ui-panel">
              {!packing.length ? (
                <EmptyState title="Packing list is empty">
                  Add the things you want to bring.
                </EmptyState>
              ) : (
                packing.map((item) => (
                  <Row
                    key={item.id}
                    leading={
                      <input
                        type="checkbox"
                        className="ui-check"
                        aria-label={`Pack ${item.label}`}
                        checked={!!item.checked}
                        disabled={busy}
                        onChange={() => act(() => model.toggle(item))}
                      />
                    }
                    title={
                      <span
                        style={
                          item.checked
                            ? {
                                textDecoration: "line-through",
                                color: "var(--text-secondary)",
                              }
                            : {}
                        }
                      >
                        {item.label}
                      </span>
                    }
                    actions={
                      <>
                        <span className="ui-badge">× {item.quantity}</span>
                        <Button
                          variant="ghost"
                          aria-label={`Remove ${item.label}`}
                          onClick={() =>
                            setRemoving({
                              type: "packing",
                              id: item.id,
                              label: item.label,
                            })
                          }
                        >
                          ×
                        </Button>
                      </>
                    }
                  />
                ))
              )}
            </div>
          </section>
        </div>
      )}
      {dialog === "trip" && (
        <TripForm
          busy={busy}
          onClose={close}
          onSave={(f) =>
            act(async () => {
              const row = await model.addTrip(f);
              setSelected(row.id);
            }).then((ok) => ok && setDialog(""))
          }
        />
      )}
      {dialog === "item" && trip && (
        <ItemForm
          trip={trip}
          busy={busy}
          onClose={close}
          onSave={(f) =>
            act(() => model.addItem(f)).then((ok) => ok && setDialog(""))
          }
        />
      )}
      {dialog === "packing" && trip && (
        <PackingForm
          trip={trip}
          busy={busy}
          onClose={close}
          onSave={(f) =>
            act(() => model.addPacking(f)).then((ok) => ok && setDialog(""))
          }
        />
      )}
      {removing && (
        <Modal title="Remove item?" onClose={() => !busy && setRemoving(null)}>
          <p className="ui-muted">Remove {removing.label} from this trip?</p>
          <div className="ui-form-actions">
            <Button disabled={busy} onClick={() => setRemoving(null)}>
              Cancel
            </Button>
            <Button
              variant="danger"
              disabled={busy}
              onClick={() =>
                act(() =>
                  removing.type === "item"
                    ? model.removeItem(removing.id)
                    : model.removePacking(removing.id),
                ).then((ok) => ok && setRemoving(null))
              }
            >
              Remove item
            </Button>
          </div>
        </Modal>
      )}
    </Page>
  );
}
createRoot(document.getElementById("root")).render(<App />);