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
7 changes: 5 additions & 2 deletions module/docs/plugin-manifest-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,11 @@ has three node kinds:
one metric. These metric-bound widgets are distinct from `ui.widgets`, which
are presentation slots contributed to host surfaces; the two never mix.

On a registered solution's page the host renders every declared dashboard, and
each viewer can arrange it for themselves: reorder its declared widgets, remove
On a registered solution's page the host renders every declared dashboard in a
**Dashboard** tab beside the solution's own **App** tab, never stacked above
the solution; the solution stays mounted while the dashboard is open. A
solution that declares no dashboard gets no tab bar. Each viewer can arrange a
dashboard for themselves: reorder its declared widgets, remove
them, and re-add the ones they removed. The declared widgets, in declared order,
are where every viewer starts, and a widget declared later reaches viewers who
already rearranged. A viewer's arrangement is a preference (ADR 0007): it never
Expand Down
8 changes: 4 additions & 4 deletions module/services/frontend/code/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions module/services/frontend/code/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@
"@base-ui/react": "^1.3.0",
"@bufbuild/protobuf": "^2.11.0",
"@codefly-dev/saas-sdk": "0.3.15",
"@codefly-dev/saas-ui": "0.11.3",
"@codefly-dev/ui": "0.11.3",
"@codefly-dev/saas-ui": "0.11.4",
"@codefly-dev/ui": "0.11.4",
"@codefly/saas-plugin-contract": "2.3.0",
"@codefly/saas-plugin-manifest": "0.1.0",
"@codefly/saas-plugin-react": "0.4.1",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@codefly-dev/ui",
"version": "0.11.3",
"version": "0.11.4",
"description": "The shared, versioned Codefly SaaS frontend kit: the plugin host and the tokens-as-data skin mechanism, standalone so the host and Module-Federation remotes import and dedupe one copy.",
"repository": {
"type": "git",
Expand Down
13 changes: 13 additions & 0 deletions module/services/frontend/code/packages/saas-ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,19 @@ role via Accounts' registration/grant service paths. Only `admin@acme.com` and
a demo source; other fixture viewers remain ungranted. Reseeding reconverges the
declared grants, so test revocation without restarting the fixture runtime.

## Audit

`<LastLogin at subject? label? />` says when someone last signed in the way a
person reads it ("Last login · 12 seconds ago"), and keeps it true as time
passes: it re-says itself every second for the first minute, then every 30
seconds, then every five minutes. The exact time is on the `<time>` element
(`dateTime`, and a `title` for hover) and on the line beneath. `at` is an ISO
string or a `Date`; `null`, or anything that is not a date, reads "Never" with
"No sign-in is recorded yet", never "Invalid Date". A solution hands it the
value it read (from the audit log, say) and owns nothing of how it is said.
`relativeTime(at, now, locale?)` is its words alone, counting whole units
elapsed through `Intl.RelativeTimeFormat`, for a consumer's own shell.

## Installing from a solution

This package is published to GitHub Packages under the org's `@codefly-dev`
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@codefly-dev/saas-ui",
"version": "0.11.3",
"version": "0.11.4",
"description": "Reusable SaaS-domain frontend components (datasources, \u2026) the portal and solutions both import, built on @codefly-dev/ui primitives and driven by an injected typed client.",
"repository": {
"type": "git",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { act, cleanup, render, screen } from "@testing-library/react";
import { renderToString } from "react-dom/server";
import { afterEach, describe, expect, it, vi } from "vitest";
import { LastLogin, relativeTime } from "../last-login.js";

afterEach(() => {
cleanup();
vi.useRealTimers();
});

const at = new Date("2026-10-02T10:00:00Z");
const after = (seconds: number) => new Date(at.getTime() + seconds * 1000);

describe("relativeTime", () => {
it("says how long ago, in the largest unit that fits", () => {
expect(relativeTime(at, after(12), "en")).toBe("12 seconds ago");
expect(relativeTime(at, after(90), "en")).toBe("1 minute ago");
expect(relativeTime(at, after(120), "en")).toBe("2 minutes ago");
expect(relativeTime(at, after(3 * 3600), "en")).toBe("3 hours ago");
expect(relativeTime(at, after(26 * 3600), "en")).toBe("yesterday");
expect(relativeTime(at, after(10 * 24 * 3600), "en")).toBe("last week");
});

it("counts whole units elapsed, never rounding up to the next", () => {
const almost = (seconds: number) => new Date(at.getTime() + seconds * 1000 - 1);
expect(relativeTime(at, almost(60), "en")).toBe("59 seconds ago");
expect(relativeTime(at, after(60), "en")).toBe("1 minute ago");
expect(relativeTime(at, almost(3600), "en")).toBe("59 minutes ago");
expect(relativeTime(at, after(3600), "en")).toBe("1 hour ago");
});

it("says now for this second, and for a clock that runs ahead", () => {
expect(relativeTime(at, at, "en")).toBe("now");
expect(relativeTime(after(5), at, "en")).toBe("now");
});
});

describe("<LastLogin>", () => {
it("says when, with the exact time on the element and beside it", () => {
render(<LastLogin at={at.toISOString()} subject="ana@example.com" now={() => after(12)} />);
expect(screen.getByText("Last login")).toBeTruthy();
const time = screen.getByText(/12 seconds ago/);
expect(time.tagName).toBe("TIME");
expect(time.getAttribute("datetime")).toBe(at.toISOString());
expect(screen.getByText(/ana@example\.com/)).toBeTruthy();
});

it("stays true as time passes", () => {
vi.useFakeTimers();
vi.setSystemTime(after(10));
render(<LastLogin at={at} />);
expect(screen.getByText(/10 seconds ago/)).toBeTruthy();
act(() => {
vi.advanceTimersByTime(5_000);
});
expect(screen.getByText(/15 seconds ago/)).toBeTruthy();
});

// The server and the browser's hydration render must say the same thing,
// whatever either one's clock or timezone: the time in UTC. The relative
// words and the viewer's local time follow once mounted.
it("renders the same first text on any clock, so hydration matches", () => {
const html = renderToString(<LastLogin at={at} now={() => after(12)} />);
expect(html).toContain("2026-10-02 10:00 UTC");
expect(html).not.toContain("seconds ago");
expect(renderToString(<LastLogin at={at} now={() => after(999)} />)).toBe(html);
});

// A parent that re-renders more often than the words change, handing a new
// clock function each time, must not hold them still.
it("keeps moving while its parent re-renders every five seconds", () => {
vi.useFakeTimers();
vi.setSystemTime(after(5 * 60));
const { rerender } = render(<LastLogin at={at} now={() => new Date()} />);
expect(screen.getByText("5 minutes ago")).toBeTruthy();
for (let i = 0; i < 24; i++) {
act(() => {
vi.advanceTimersByTime(5_000);
});
rerender(<LastLogin at={at} now={() => new Date()} />);
}
expect(screen.getByText("7 minutes ago")).toBeTruthy();
});

it("says no sign-in is recorded, never an invalid date", () => {
render(<LastLogin at={null} />);
expect(screen.getByText("Never")).toBeTruthy();
expect(screen.queryByText(/Invalid Date|NaN/)).toBeNull();
cleanup();
render(<LastLogin at="not a date" />);
expect(screen.getByText("Never")).toBeTruthy();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
"use client";

import { useEffect, useRef, useState } from "react";
import { cn } from "../datasources/util.js";

/** The units a relative time is said in, largest first, in seconds. */
const UNITS: readonly [Intl.RelativeTimeFormatUnit, number][] = [
["year", 365 * 24 * 3600],
["month", 30 * 24 * 3600],
["week", 7 * 24 * 3600],
["day", 24 * 3600],
["hour", 3600],
["minute", 60],
["second", 1],
];

/**
* How long ago `at` was, from `now`, in words: "12 seconds ago", "3 hours ago",
* "yesterday", counting whole units elapsed: 59.999 seconds is still "59
* seconds ago". Pure, so a consumer can say it in its own shell. A moment in
* the future (a clock ahead of this one) reads as "now" rather than "in 2
* seconds".
*/
export function relativeTime(at: Date, now: Date, locale?: string): string {
const format = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
const elapsed = Math.floor((now.getTime() - at.getTime()) / 1000);
if (elapsed <= 0) return format.format(0, "second");
for (const [unit, size] of UNITS) {
if (elapsed >= size) return format.format(-Math.floor(elapsed / size), unit);
}
return format.format(0, "second");
}

/** How long until the words must be re-said to stay true. */
function cadence(at: number, now: number): number {
const elapsed = now - at;
return elapsed < 60_000 ? 1_000 : elapsed < 3_600_000 ? 30_000 : 300_000;
}

/** The system clock, one function for every render so it is never a new
* dependency. */
const systemNow = () => new Date();

/** What the server renders and the browser's first render repeats: the time in
* UTC, which depends on neither environment's clock, timezone nor locale. */
function stableTime(at: Date): string {
return `${at.toISOString().slice(0, 16).replace("T", " ")} UTC`;
}

export interface LastLoginProps {
/** When the person last signed in, as an ISO string or a Date; null when
* no sign-in is recorded. */
at: string | Date | null;
/** Who signed in, when it should be said (an email, a name). */
subject?: string;
/** The label above the time. Default "Last login". */
label?: string;
/** The clock, for tests and stories. Default the system clock. */
now?: () => Date;
className?: string;
}

/**
* When someone last signed in, as a person reads it: "Last login · 12 seconds
* ago", kept true as time passes, with the exact time on hover and for
* assistive technology (`<time dateTime title>`). A solution hands it the value
* it read — from the audit log, say — and owns nothing of how it is said.
*
* The first render, on the server and again in the browser while it hydrates,
* says the time in UTC, which no clock or timezone can change between the two;
* once mounted it reads the browser's clock and says it relatively, in the
* viewer's own timezone. Its re-saying is scheduled from the time itself, so a
* parent that re-renders often cannot hold it still.
*
* It is the kit's rather than each solution's because every page that shows a
* sign-in time otherwise writes its own "x ago" with its own thresholds; the
* host already has two that disagree.
*/
export function LastLogin({ at, subject, label = "Last login", now = systemNow, className }: LastLoginProps) {
const when = at === null ? null : at instanceof Date ? at : new Date(at);
const atMs = when === null ? Number.NaN : when.getTime();
const valid = !Number.isNaN(atMs);
// The clock is read through a ref, so a caller handing a new function on
// every render does not restart the schedule below.
const clock = useRef(now);
useEffect(() => {
clock.current = now;
}, [now]);
// null until mounted: the first render is the hydration render.
const [current, setCurrent] = useState<Date | null>(null);
useEffect(() => {
if (Number.isNaN(atMs)) return;
let timer: ReturnType<typeof setTimeout>;
const say = () => {
const read = clock.current();
setCurrent(read);
timer = setTimeout(say, cadence(atMs, read.getTime()));
};
say();
return () => clearTimeout(timer);
}, [atMs]);

return (
<div data-slot="last-login" className={cn("space-y-1", className)}>
<div className="type-metric-label text-muted-foreground">{label}</div>
{valid ? (
<>
<time
dateTime={when!.toISOString()}
title={current ? when!.toLocaleString() : stableTime(when!)}
className="type-metric-value-lg tabular-nums"
>
{current ? relativeTime(when!, current) : stableTime(when!)}
</time>
<p className="type-caption-plain text-muted-foreground">
{current ? when!.toLocaleString() : stableTime(when!)}
{subject ? <> · {subject}</> : null}
</p>
</>
) : (
<>
<div className="type-metric-value-lg text-muted-foreground">Never</div>
<p className="type-caption-plain text-muted-foreground">No sign-in is recorded yet.</p>
</>
)}
</div>
);
}
3 changes: 3 additions & 0 deletions module/services/frontend/code/packages/saas-ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,6 @@ export {
viewerIdentity,
viewerOrganization,
} from "./solution/index.js";
// When someone last signed in, said as a person reads it and kept true as time
// passes; relativeTime is its words alone, for a consumer's own shell.
export { LastLogin, type LastLoginProps, relativeTime } from "./audit/last-login.js";
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { LastLogin } from "../src/index.js";

export default { title: "SaaS UI/Audit" };

// A fixed clock, so a story renders the same words on every run.
const now = () => new Date("2026-10-02T10:00:12Z");

export const LastLoginSecondsAgo = {
render: () => <LastLogin at="2026-10-02T10:00:00Z" subject="ana@example.com" now={now} />,
};

export const LastLoginYesterday = {
render: () => <LastLogin at="2026-10-01T08:30:00Z" now={now} />,
};

export const LastLoginNeverRecorded = {
render: () => <LastLogin at={null} now={now} />,
};
Loading
Loading