Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/studio-ended-collection-rule.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudhal/cli": patch
---

Studio shows **Ended (collection rule)** beside the status of a published entry whose collection's expiry has passed (RFC-0042 §3.3, #113), in the collection list and in the entry editor's header. Until now such an entry looked plainly published while the site had already left it out of lists, or answered "not found". The badge explains itself on hover, from the keyboard and with a tap on touch screens. It names the date field, when the entry ended in the site's time zone, and what the site does: the page keeps its address but leaves lists, feeds, the sitemap and search, or the page answers "not found". Drafts, embargoed entries and entries whose own "Hide after" has passed get no badge (their own dates show in the editor's Schedule card). A translation reads its original's date only while the original is published, as the site does. The badge and its words load only for collections that have an expiry. Translations of the new words are AI drafts awaiting review.
48 changes: 48 additions & 0 deletions apps/studio/src/components/EntryStatus.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* An entry's status badge, followed by "Ended (collection rule)" when its collection's expiry has
* passed (RFC-0042 §3.3, DESIGN.md §2ah): in the collection list and the entry editor's header.
* The site comes from the shared `["site", siteId]` query. Only a collection with an expiry loads
* the badge's chunk and its `listings` words; every other collection shows the status alone.
*/
import type { ContentEntry } from "@cloudhal/core-contracts";
import { useQuery } from "@tanstack/react-query";
import { lazy, Suspense } from "react";
import { withMessages } from "../i18n/index.tsx";
import { api } from "../lib/api.ts";
import { Status } from "./Status.tsx";

const EndedBadge = lazy(() =>
withMessages(import("./listings/EndedBadge.tsx"), ["listings"]).then((m) => ({
default: m.EndedBadge,
})),
);

/**
* `entry` gives the status word (the editor's draft); `saved` (the saved entry, `entry` by
* default) decides the collection rule, so a half-typed date does not flip the badge.
*/
export function EntryStatus({
siteId,
entry,
saved = entry,
}: {
siteId: string;
entry: ContentEntry;
saved?: ContentEntry | undefined;
}) {
const site = useQuery({
queryKey: ["site", siteId],
queryFn: () => api.sites.get(siteId),
enabled: siteId !== "",
}).data;
return (
<span className="inline-flex flex-wrap items-center gap-1.5">
<Status status={entry.status} />
{site?.collections?.[saved.collection]?.expiry && (
<Suspense fallback={null}>
<EndedBadge siteId={siteId} site={site} entry={saved} />
</Suspense>
)}
</span>
);
}
82 changes: 82 additions & 0 deletions apps/studio/src/components/listings/EndedBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import type { ContentEntry, Site } from "@cloudhal/core-contracts";
import { fieldLabel } from "@cloudhal/core-ts/fields";
import { ToggletipBadge } from "@cloudhal/ui";
import { CalendarX } from "@phosphor-icons/react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { formatDate, useI18n } from "../../i18n/index.tsx";
import { api } from "../../lib/api.ts";
import { zoneCity } from "../../lib/schedules.ts";
import { useNow } from "../useNow.ts";
import { endedByRule, gateZone, originalIdOf } from "./ended-rule.ts";

/**
* The original a translation reads its non-localized date from: the newer of two caches. The
* collection list's unpaged `["entries", siteId, collection]` is read without ever fetching it,
* and only while it is not invalidated (while it loads, this waits rather than asking once per
* row); the shared `["entry", siteId, group]` query, which the editor's Fields card uses and a
* save writes, is asked for otherwise. So a list left stale by a save is never preferred to the
* saved original, and the editor asks once.
*/
function useGateOriginal(siteId: string, entry: ContentEntry): ContentEntry | undefined {
const qc = useQueryClient();
const id = originalIdOf(entry);
const listKey = ["entries", siteId, entry.collection];
const list = useQuery({
queryKey: listKey,
queryFn: () => api.entries.list(siteId, entry.collection),
enabled: false,
});
const fresh = qc.getQueryState(listKey)?.isInvalidated === false;
const listed = id === undefined || !fresh ? undefined : list.data?.find((e) => e.id === id);
const one = useQuery({
queryKey: ["entry", siteId, id],
queryFn: () => api.entries.get(siteId, id as string),
enabled: id !== undefined && !listed && list.fetchStatus === "idle",
});
if (id === undefined) return undefined;
if (!listed) return one.data;
return one.data && one.dataUpdatedAt >= list.dataUpdatedAt ? one.data : listed;
}

/**
* "Ended (collection rule)" beside a published entry's status (RFC-0042 §3.3, DESIGN.md §2ah):
* the entry's collection expiry has passed, so the site has left it out of lists (`unlist`) or
* answers "not found" (`hide`) while it is still published. The toggletip names the date field,
* the instant in the site's zone and what the site does. Computed from the saved site settings
* and the saved entry (the editor passes `query.data`, never the unsaved draft), re-checked every
* minute. Loaded with its `listings` words only for collections that have an expiry.
*/
export function EndedBadge({
siteId,
site,
entry,
}: {
siteId: string;
site: Site;
entry: ContentEntry;
}) {
const { t, locale } = useI18n("listings");
const now = useNow(60_000);
const original = useGateOriginal(siteId, entry);
const ended = endedByRule(site, entry, original, now);
if (!ended) return null;
const { gate, fieldKey, field } = ended;
const zone = gateZone(site);
const reason = t("listings.ended.reason", {
action: gate.action,
time: formatDate(locale, gate.at, { dateStyle: "long", timeStyle: "short", timeZone: zone }),
zone: zoneCity(zone),
field: field ? fieldLabel(field, locale, site.defaultLocale) : fieldKey,
});
// Kumo's Badge does not forward data attributes; the wrapper carries the action for tests.
return (
<span data-ended-rule={gate.action} className="inline-flex">
<ToggletipBadge
variant="secondary"
icon={<CalendarX size={14} aria-hidden />}
label={t("listings.ended.badge")}
reason={reason}
/>
</span>
);
}
65 changes: 65 additions & 0 deletions apps/studio/src/components/listings/ended-rule.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* "Ended (collection rule)" in Studio (RFC-0042 §3.3, #113): whether a published entry has ended
* under its collection's expiry, computed with the same `derivedGate` / `entryVisibility` the
* site uses, so the badge and the site agree to the millisecond. The entry's own state speaks
* first, as in the renderer preview (`gateNoticeHtml || derivedGateNoticeHtml`): drafts,
* embargoed entries and entries whose own "Hide after" has passed get no badge. A translation
* reads its original only while the original is published, as the site does
* (`derivedGates(site, published)`). The gate's end is exclusive, so `now === at` has already
* ended. Pure; loads with the badge only (`LAZY_ONLY`).
*/
import type { ContentEntry, FieldDefinition, Site } from "@cloudhal/core-contracts";
import {
type DerivedGate,
derivedGate,
entryVisibility,
type FieldsOriginal,
isKnownTimeZone,
} from "@cloudhal/core-ts/fields";

/** The entry has ended under its collection's rule: the gate, the rule's field and its definition. */
export interface EndedByRule {
gate: DerivedGate;
fieldKey: string;
field: FieldDefinition | undefined;
}

/** A translation's original as the rule reads it: its values and whether it is published. */
export type GateOriginal = FieldsOriginal & Pick<ContentEntry, "status">;

/**
* The collection's derived gate when it, and nothing of the entry's own, keeps the entry off the
* site at `now`: published, its own `availability` open, and the derived gate passed.
* `undefined` otherwise (no rule, no readable value, not yet ended, or the entry's own state).
* A draft, in-review or archived original is not read, as on the site (it takes originals from
* published entries only): the translation then reads only its own values, which hold no
* non-localized date, so it stays listed.
*/
export function endedByRule(
site: Site,
entry: ContentEntry,
original: GateOriginal | undefined,
now: number,
): EndedByRule | undefined {
const gate = derivedGate(site, entry, original?.status === "published" ? original : undefined);
if (!gate) return undefined;
if (entryVisibility(entry, undefined, now) !== "shown") return undefined;
if (entryVisibility(entry, gate, now) === "shown") return undefined;
const settings = site.collections?.[entry.collection];
const fieldKey = settings?.expiry?.field ?? "";
return { gate, fieldKey, field: settings?.fields?.find((d) => d.key === fieldKey) };
}

/** The zone the site's gates use, for showing the instant: the site's, or UTC when unknown. */
export function gateZone(site: Pick<Site, "timeZone">): string {
return site.timeZone && isKnownTimeZone(site.timeZone) ? site.timeZone : "UTC";
}

/**
* The id of the same-site original a translation reads its non-localized values from
* (EntryFieldsCard's `useOriginal` rule); `undefined` for originals and language editions.
*/
export function originalIdOf(entry: Pick<ContentEntry, "id" | "translation">): string | undefined {
const tr = entry.translation;
return tr && tr.group !== entry.id && !tr.source?.siteId ? tr.group : undefined;
}
1 change: 1 addition & 0 deletions apps/studio/src/i18n/generated/params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,7 @@ export interface MessageParams {
"listings.contentList.ops.lt": { kind: "time" | "number" };
"listings.contentList.ops.lte": { kind: "time" | "number" };
"listings.createPage.exists": { path: string | number };
"listings.ended.reason": { action: "unlist" | "hide"; time: string | number; zone: string | number; field: string | number };
"listings.expiry.past": { count: number };
"listings.filters.any": { label: string | number };
"listings.filters.is": { label: string | number; value: string | number };
Expand Down
7 changes: 6 additions & 1 deletion apps/studio/src/i18n/lazy/listings.ar.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Generated by `pnpm i18n:import` (ADR-0034): ar `listings.*` messages.
// Do not edit: change the translation file and import it; only English is edited here.
// hash: 0091155c70e9111a
// hash: 1359deb13435c6a1
import type { NamespaceMessages } from "../namespaces.ts";

export const messages: NamespaceMessages<"listings"> = {
Expand Down Expand Up @@ -49,6 +49,11 @@ export const messages: NamespaceMessages<"listings"> = {
gatesUnsupported:
"لا يطبّق الخادم المباشر لهذا الموقع الانتهاء بعد. وزّع الموقع أولًا، ثم احفظ الانتهاء مرة أخرى.",
},
ended: {
badge: "انتهى (قاعدة المجموعة)",
reason:
"{action, select, unlist {انتهى هذا الإدخال في {time} (بتوقيت {zone}) بموجب قاعدة انتهاء مجموعته على «{field}». لا يزال منشورًا وتبقى صفحته على عنوانها، لكنه خرج من القوائم والخلاصات وخريطة الموقع والبحث.} hide {انتهى هذا الإدخال في {time} (بتوقيت {zone}) بموجب قاعدة انتهاء مجموعته على «{field}». لا يزال منشورًا، لكن صفحته تجيب بـ«غير موجود»، وقد خرج من كل قائمة وخلاصة ومن البحث.} other {انتهى هذا الإدخال في {time} (بتوقيت {zone}) بموجب قاعدة انتهاء مجموعته على «{field}».}}",
},
structuredData: {
title: "البيانات المنظمة",
description:
Expand Down
7 changes: 6 additions & 1 deletion apps/studio/src/i18n/lazy/listings.de.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Generated by `pnpm i18n:import` (ADR-0034): de `listings.*` messages.
// Do not edit: change the translation file and import it; only English is edited here.
// hash: d026945593c61937
// hash: c4f413070f5457f2
import type { NamespaceMessages } from "../namespaces.ts";

export const messages: NamespaceMessages<"listings"> = {
Expand Down Expand Up @@ -52,6 +52,11 @@ export const messages: NamespaceMessages<"listings"> = {
gatesUnsupported:
"Der Live-Server dieser Website wendet den Ablauf noch nicht an. Deploye zuerst die Website und speichere den Ablauf dann erneut.",
},
ended: {
badge: "Beendet (Sammlungsregel)",
reason:
"{action, select, unlist {Dieser Eintrag endete am {time} ({zone}-Zeit) durch die Ablaufregel seiner Sammlung für „{field}“. Er ist weiterhin veröffentlicht und seine Seite bleibt unter ihrer Adresse, aber er ist aus Listen, Feeds, der Sitemap und der Suche verschwunden.} hide {Dieser Eintrag endete am {time} ({zone}-Zeit) durch die Ablaufregel seiner Sammlung für „{field}“. Er ist weiterhin veröffentlicht, aber seine Seite antwortet mit „nicht gefunden“, und er ist aus allen Listen, Feeds und der Suche verschwunden.} other {Dieser Eintrag endete am {time} ({zone}-Zeit) durch die Ablaufregel seiner Sammlung für „{field}“.}}",
},
structuredData: {
title: "Strukturierte Daten",
description:
Expand Down
19 changes: 19 additions & 0 deletions apps/studio/src/i18n/lazy/listings.en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,25 @@ export const messages = {
gatesUnsupported:
"This site's live server doesn't apply expiry yet. Deploy the site first, then save the expiry again.",
},
/**
* RFC-0042 §3.3: a published entry whose collection's expiry has passed. Its status stays
* Published; this badge follows the status in the collection list and the editor's header.
*/
ended: {
/**
* Badge beside the status in the collection list and the entry editor's header. Must read
* differently from an entry's own end ("Expired {time}", schedules.card.state.expired) and
* must say it comes from the collection rule.
*/
badge: "Ended (collection rule)",
/**
* The badge's tooltip. `time` is a date and time already formatted in the site's time zone,
* `zone` the zone's city ("Istanbul", "UTC"), `field` the date field's label, `action`
* unlist or hide. Keep both branches and `other`; never put an apostrophe directly before `{`.
*/
reason:
"{action, select, unlist {This entry ended on {time} ({zone} time) under its collection's expiry on “{field}”. It's still published and its page stays at its address, but it has left lists, feeds, the sitemap and search.} hide {This entry ended on {time} ({zone} time) under its collection's expiry on “{field}”. It's still published, but its page answers “not found”, and it has left every list, feed and search.} other {This entry ended on {time} ({zone} time) under its collection's expiry on “{field}”.}}",
},
structuredData: {
title: "Structured data",
description:
Expand Down
7 changes: 6 additions & 1 deletion apps/studio/src/i18n/lazy/listings.es.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Generated by `pnpm i18n:import` (ADR-0034): es `listings.*` messages.
// Do not edit: change the translation file and import it; only English is edited here.
// hash: 1a559d2468df917b
// hash: f9daf913ef38b355
import type { NamespaceMessages } from "../namespaces.ts";

export const messages: NamespaceMessages<"listings"> = {
Expand Down Expand Up @@ -53,6 +53,11 @@ export const messages: NamespaceMessages<"listings"> = {
gatesUnsupported:
"El servidor en vivo de este sitio aún no aplica la caducidad. Despliega primero el sitio y luego vuelve a guardar la caducidad.",
},
ended: {
badge: "Finalizada (regla de la colección)",
reason:
"{action, select, unlist {Esta entrada finalizó el {time} (hora de {zone}) por la regla de caducidad de su colección sobre «{field}». Sigue publicada y su página sigue en su dirección, pero ha salido de las listas, los feeds, el sitemap y la búsqueda.} hide {Esta entrada finalizó el {time} (hora de {zone}) por la regla de caducidad de su colección sobre «{field}». Sigue publicada, pero su página responde «no encontrada» y ha salido de todas las listas, feeds y búsquedas.} other {Esta entrada finalizó el {time} (hora de {zone}) por la regla de caducidad de su colección sobre «{field}».}}",
},
structuredData: {
title: "Datos estructurados",
description:
Expand Down
7 changes: 6 additions & 1 deletion apps/studio/src/i18n/lazy/listings.fa.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Generated by `pnpm i18n:import` (ADR-0034): fa `listings.*` messages.
// Do not edit: change the translation file and import it; only English is edited here.
// hash: a4045f73a5d8d0c6
// hash: b63ab0cfe610e162
import type { NamespaceMessages } from "../namespaces.ts";

export const messages: NamespaceMessages<"listings"> = {
Expand Down Expand Up @@ -51,6 +51,11 @@ export const messages: NamespaceMessages<"listings"> = {
gatesUnsupported:
"سرور زندهٔ این سایت هنوز انقضا را اعمال نمی‌کند. ابتدا سایت را مستقر کنید و سپس انقضا را دوباره ذخیره کنید.",
},
ended: {
badge: "پایان‌یافته (قاعدهٔ مجموعه)",
reason:
"{action, select, unlist {این ورودی طبق قاعدهٔ انقضای مجموعه‌اش بر اساس «{field}» در {time} (به وقت {zone}) به پایان رسید. هنوز منتشرشده است و صفحه‌اش در نشانی خود می‌ماند، اما از فهرست‌ها، فیدها، sitemap و جستجو بیرون رفته است.} hide {این ورودی طبق قاعدهٔ انقضای مجموعه‌اش بر اساس «{field}» در {time} (به وقت {zone}) به پایان رسید. هنوز منتشرشده است، اما صفحه‌اش پاسخ «پیدا نشد» می‌دهد و از همهٔ فهرست‌ها، فیدها و جستجو بیرون رفته است.} other {این ورودی طبق قاعدهٔ انقضای مجموعه‌اش بر اساس «{field}» در {time} (به وقت {zone}) به پایان رسید.}}",
},
structuredData: {
title: "داده‌های ساختاریافته",
description:
Expand Down
7 changes: 6 additions & 1 deletion apps/studio/src/i18n/lazy/listings.fr.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Generated by `pnpm i18n:import` (ADR-0034): fr `listings.*` messages.
// Do not edit: change the translation file and import it; only English is edited here.
// hash: f3f0c89b2f0fba4f
// hash: a42c0843d9a6c4ae
import type { NamespaceMessages } from "../namespaces.ts";

export const messages: NamespaceMessages<"listings"> = {
Expand Down Expand Up @@ -53,6 +53,11 @@ export const messages: NamespaceMessages<"listings"> = {
gatesUnsupported:
"Le serveur en ligne de ce site n’applique pas encore l’expiration. Déployez d’abord le site, puis enregistrez de nouveau l’expiration.",
},
ended: {
badge: "Terminée (règle de la collection)",
reason:
"{action, select, unlist {Cette entrée a pris fin le {time} (heure de {zone}) selon la règle d’expiration de sa collection sur « {field} ». Elle est toujours publiée et sa page reste à son adresse, mais elle a quitté les listes, les flux, le sitemap et la recherche.} hide {Cette entrée a pris fin le {time} (heure de {zone}) selon la règle d’expiration de sa collection sur « {field} ». Elle est toujours publiée, mais sa page répond « introuvable », et elle a quitté toutes les listes, tous les flux et la recherche.} other {Cette entrée a pris fin le {time} (heure de {zone}) selon la règle d’expiration de sa collection sur « {field} ».}}",
},
structuredData: {
title: "Données structurées",
description:
Expand Down
Loading
Loading