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
1 change: 1 addition & 0 deletions .agents/skills/databuddy-internal/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ Keep additions **minimal**: one bullet, a new `rg` hint, or a routing note—eno
- Public status pages render from `apps/status`; `apps/dashboard` owns status-page management/config UI only. When cleaning public status UX, update shared `@databuddy/ui/uptime` pieces or `apps/status` wrappers instead of redesigning dashboard-only route remnants.
- `packages/db`: Drizzle Postgres schema, client, and ClickHouse helpers
- `packages/rpc`: shared oRPC router, procedures, auth-aware server context
- `packages/rpc` must declare `drizzle-orm: "catalog:"` before importing `drizzle-orm/*` helpers such as `drizzle-orm/zod`; otherwise TypeScript can resolve a different Drizzle instance than `@databuddy/db` and reject table-derived schemas.
- `packages/auth`: Better Auth setup, permissions, organization access
- `packages/env`: per-app env schemas
- `packages/shared`: shared types, flags, analytics schemas, utilities
Expand Down
5 changes: 4 additions & 1 deletion apps/dashboard/app/(dby)/dby/og/brand.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ const FONT_DIR = path.join(process.cwd(), "fonts", "lt-superior");
let ogFontsPromise: ReturnType<typeof readOgFonts> | undefined;

export function loadOgFonts() {
ogFontsPromise ??= readOgFonts();
ogFontsPromise ??= readOgFonts().catch((error) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The OG font-loading cache and its new rejection-reset behavior are maintained in two exact copies: this dashboard file and apps/docs/lib/og.tsx (the files are byte-for-byte identical). Because the fix had to be applied in both places, future changes to error handling, font paths, or returned font metadata can easily drift if one copy is updated without the other. Consider either extracting this into a shared package utility or adding cross-reference comments at the top of each file so future edits stay aligned.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/dashboard/app/(dby)/dby/og/brand.tsx, line 24:

<comment>The OG font-loading cache and its new rejection-reset behavior are maintained in two exact copies: this dashboard file and `apps/docs/lib/og.tsx` (the files are byte-for-byte identical). Because the fix had to be applied in both places, future changes to error handling, font paths, or returned font metadata can easily drift if one copy is updated without the other. Consider either extracting this into a shared package utility or adding cross-reference comments at the top of each file so future edits stay aligned.</comment>

<file context>
@@ -21,7 +21,10 @@ const FONT_DIR = path.join(process.cwd(), "fonts", "lt-superior");
 
 export function loadOgFonts() {
-	ogFontsPromise ??= readOgFonts();
+	ogFontsPromise ??= readOgFonts().catch((error) => {
+		ogFontsPromise = undefined;
+		throw error;
</file context>

ogFontsPromise = undefined;
throw error;
});
return ogFontsPromise;
}

Expand Down
240 changes: 116 additions & 124 deletions apps/dashboard/app/(main)/links/_components/expiration-picker.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useCallback, useMemo, useState } from "react";
import { useState } from "react";
import {
Popover,
PopoverContent,
Expand All @@ -17,13 +17,7 @@ import {
import { Calendar } from "@databuddy/ui/client";
import { Button, dayjs } from "@databuddy/ui";

interface ExpirationPreset {
getDate: () => Date;
label: string;
value: string;
}

const EXPIRATION_PRESETS: ExpirationPreset[] = [
const EXPIRATION_PRESETS = [
{
label: "1 hour",
value: "1h",
Expand Down Expand Up @@ -56,6 +50,8 @@ const EXPIRATION_PRESETS: ExpirationPreset[] = [
},
];

type ExpirationPreset = (typeof EXPIRATION_PRESETS)[number];

function formatPresetPreview(preset: ExpirationPreset): string {
const date = dayjs(preset.getDate());
const now = dayjs();
Expand All @@ -71,6 +67,36 @@ function formatPresetPreview(preset: ExpirationPreset): string {
return date.format("MMM D, YYYY");
}

function formatDisplay(date: Date | null): string {
if (!date) {
return "Never expires";
}

const now = dayjs();
const target = dayjs(date);
const diffHours = target.diff(now, "hour");
const diffDays = target.diff(now, "day");

if (diffHours < 0) {
return `Expired ${target.fromNow()}`;
}

if (diffHours < 24) {
return `Expires in ${diffHours}h · ${target.format("h:mm A")}`;
}

if (diffDays < 7) {
return `Expires in ${diffDays}d · ${target.format("ddd, MMM D")}`;
}

return `Expires ${target.format("MMM D, YYYY")}`;
}

function withTime(date: Date, time: string) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: The withTime helper crashes at runtime when the type="time" input value is an empty string. time.split(":").map(Number) on "" yields [NaN], so minutes is undefined; Day.js then interprets .minute(undefined) as a getter and returns a number, making the next .second(0) call throw TypeError. Because withTime is now called during render for the preview text (whenever picker.customDate is set), simply clearing the time input after choosing a custom date will crash the component. Add a guard for empty or malformed time strings before calling withTime, or make the helper return null / throw a controlled error so the UI can degrade gracefully.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/dashboard/app/(main)/links/_components/expiration-picker.tsx, line 95:

<comment>The `withTime` helper crashes at runtime when the `type="time"` input value is an empty string. `time.split(":").map(Number)` on `""` yields `[NaN]`, so `minutes` is `undefined`; Day.js then interprets `.minute(undefined)` as a getter and returns a number, making the next `.second(0)` call throw `TypeError`. Because `withTime` is now called during render for the preview text (whenever `picker.customDate` is set), simply clearing the time input after choosing a custom date will crash the component. Add a guard for empty or malformed time strings before calling `withTime`, or make the helper return `null` / throw a controlled error so the UI can degrade gracefully.</comment>

<file context>
@@ -71,6 +67,36 @@ function formatPresetPreview(preset: ExpirationPreset): string {
+	return `Expires ${target.format("MMM D, YYYY")}`;
+}
+
+function withTime(date: Date, time: string) {
+	const [hours, minutes] = time.split(":").map(Number);
+	return dayjs(date).hour(hours).minute(minutes).second(0);
</file context>

const [hours, minutes] = time.split(":").map(Number);
return dayjs(date).hour(hours).minute(minutes).second(0);
}

interface ExpirationPickerProps {
className?: string;
onChange: (value: string) => void;
Expand All @@ -82,111 +108,71 @@ export function ExpirationPicker({
onChange,
className,
}: ExpirationPickerProps) {
const [isOpen, setIsOpen] = useState(false);
const [showCustom, setShowCustom] = useState(false);
const [customDate, setCustomDate] = useState<Date | undefined>(undefined);
const [customTime, setCustomTime] = useState("12:00");

const currentDate = useMemo(() => {
if (!value) {
return null;
}
const parsed = dayjs(value);
return parsed.isValid() ? parsed.toDate() : null;
}, [value]);

const activePreset = useMemo(() => {
if (!currentDate) {
return null;
}
const target = dayjs(currentDate);

for (const preset of EXPIRATION_PRESETS) {
const presetDate = dayjs(preset.getDate());
const diffMinutes = Math.abs(target.diff(presetDate, "minute"));
if (diffMinutes < 5) {
return preset.value;
}
}
return "custom";
}, [currentDate]);

const formatDisplay = useCallback((date: Date | null) => {
if (!date) {
return "Never expires";
}

const now = dayjs();
const target = dayjs(date);
const diffHours = target.diff(now, "hour");
const diffDays = target.diff(now, "day");

if (diffHours < 0) {
return `Expired ${target.fromNow()}`;
}

if (diffHours < 24) {
return `Expires in ${diffHours}h · ${target.format("h:mm A")}`;
}

if (diffDays < 7) {
return `Expires in ${diffDays}d · ${target.format("ddd, MMM D")}`;
}

return `Expires ${target.format("MMM D, YYYY")}`;
}, []);

const handlePresetSelect = useCallback(
(preset: ExpirationPreset) => {
const date = preset.getDate();
onChange(dayjs(date).format("YYYY-MM-DDTHH:mm"));
setShowCustom(false);
setIsOpen(false);
},
[onChange]
);
const [picker, setPicker] = useState({
customDate: undefined as Date | undefined,
customTime: "12:00",
isOpen: false,
showCustom: false,
});
const parsedValue = value ? dayjs(value) : null;
const currentDate = parsedValue?.isValid() ? parsedValue.toDate() : null;
const activePreset = currentDate
? (EXPIRATION_PRESETS.find(
(preset) =>
Math.abs(dayjs(currentDate).diff(dayjs(preset.getDate()), "minute")) <
5
)?.value ?? "custom")
: null;
const isExpired = currentDate && dayjs(currentDate).isBefore(dayjs());

const handleClear = useCallback(() => {
const closePicker = () => {
setPicker((state) => ({ ...state, isOpen: false, showCustom: false }));
};
const showPresets = () => {
setPicker((state) => ({ ...state, showCustom: false }));
};
const showCustom = () => {
setPicker((state) => ({ ...state, showCustom: true }));
};

const selectPreset = (preset: ExpirationPreset) => {
onChange(dayjs(preset.getDate()).format("YYYY-MM-DDTHH:mm"));
closePicker();
};

const clearExpiration = () => {
onChange("");
setShowCustom(false);
setIsOpen(false);
}, [onChange]);

const handleCustomDateSelect = useCallback((date: Date | undefined) => {
setCustomDate(date);
}, []);
closePicker();
};

const handleApplyCustom = useCallback(() => {
if (!customDate) {
const applyCustom = () => {
if (!picker.customDate) {
return;
}

const [hours, minutes] = customTime.split(":").map(Number);
const finalDate = dayjs(customDate).hour(hours).minute(minutes).second(0);

onChange(finalDate.format("YYYY-MM-DDTHH:mm"));
setShowCustom(false);
setIsOpen(false);
}, [customDate, customTime, onChange]);

const handleOpenChange = useCallback(
(open: boolean) => {
setIsOpen(open);
if (open) {
setCustomDate(currentDate || undefined);
setCustomTime(
currentDate ? dayjs(currentDate).format("HH:mm") : "12:00"
);
setShowCustom(false);
}
},
[currentDate]
);

const isExpired = currentDate && dayjs(currentDate).isBefore(dayjs());
onChange(
withTime(picker.customDate, picker.customTime).format("YYYY-MM-DDTHH:mm")
);
closePicker();
};

return (
<Popover onOpenChange={handleOpenChange} open={isOpen}>
<Popover
onOpenChange={(isOpen) => {
setPicker((state) => ({
...state,
customDate: isOpen ? (currentDate ?? undefined) : state.customDate,
customTime: isOpen
? currentDate
? dayjs(currentDate).format("HH:mm")
: "12:00"
: state.customTime,
isOpen,
showCustom: isOpen ? false : state.showCustom,
}));
}}
open={picker.isOpen}
>
<div className="relative flex items-center">
<PopoverTrigger asChild>
<Button
Expand Down Expand Up @@ -221,7 +207,7 @@ export function ExpirationPicker({
className="absolute right-2 rounded p-0.5 hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={(e) => {
e.stopPropagation();
handleClear();
clearExpiration();
}}
type="button"
>
Expand All @@ -237,13 +223,13 @@ export function ExpirationPicker({
side="bottom"
sideOffset={4}
>
{showCustom ? (
{picker.showCustom ? (
<div className="flex flex-col">
<div className="flex items-center justify-between border-b px-4 py-3">
<button
aria-label="Go back to presets"
className="rounded text-muted-foreground text-sm hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => setShowCustom(false)}
onClick={showPresets}
type="button"
>
← Back
Expand All @@ -254,11 +240,13 @@ export function ExpirationPicker({

<div className="p-3">
<Calendar
defaultMonth={customDate || new Date()}
defaultMonth={picker.customDate || new Date()}
disabled={(date) => dayjs(date).isBefore(dayjs(), "day")}
mode="single"
onSelect={handleCustomDateSelect}
selected={customDate}
onSelect={(customDate) =>
setPicker((state) => ({ ...state, customDate }))
}
selected={picker.customDate}
/>
</div>

Expand All @@ -275,38 +263,42 @@ export function ExpirationPicker({
<input
className="ml-auto h-8 rounded border bg-input px-2 text-center font-mono text-sm tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
id="expiration-time"
onChange={(e) => setCustomTime(e.target.value)}
onChange={(e) =>
setPicker((state) => ({
...state,
customTime: e.target.value,
}))
}
type="time"
value={customTime}
value={picker.customTime}
/>
</div>

{customDate && (
{picker.customDate && (
<p
aria-live="polite"
className="mt-2 text-muted-foreground text-xs"
>
Expires{" "}
{dayjs(customDate)
.hour(Number.parseInt(customTime.split(":")[0], 10))
.minute(Number.parseInt(customTime.split(":")[1], 10))
.format("MMMM D, YYYY [at] h:mm A")}
{withTime(picker.customDate, picker.customTime).format(
"MMMM D, YYYY [at] h:mm A"
)}
</p>
)}
</div>

<div className="flex items-center justify-end gap-2 border-t bg-secondary/50 px-4 py-3">
<Button
onClick={() => setShowCustom(false)}
onClick={showPresets}
size="sm"
type="button"
variant="ghost"
>
Cancel
</Button>
<Button
disabled={!customDate}
onClick={handleApplyCustom}
disabled={!picker.customDate}
onClick={applyCustom}
size="sm"
type="button"
>
Expand All @@ -333,7 +325,7 @@ export function ExpirationPicker({
: "hover:bg-secondary"
)}
key={preset.value}
onClick={() => handlePresetSelect(preset)}
onClick={() => selectPreset(preset)}
type="button"
>
<span className="text-sm">{preset.label}</span>
Expand Down Expand Up @@ -363,7 +355,7 @@ export function ExpirationPicker({
activePreset === "custom" &&
"bg-primary text-primary-foreground hover:bg-primary"
)}
onClick={() => setShowCustom(true)}
onClick={showCustom}
type="button"
>
<CalendarIcon
Expand All @@ -384,7 +376,7 @@ export function ExpirationPicker({
!value &&
"bg-primary text-primary-foreground hover:bg-primary"
)}
onClick={handleClear}
onClick={clearExpiration}
type="button"
>
<InfinityIcon
Expand Down
Loading
Loading