fix: garment upload MIME/proxy, log edit time pre-fill, date-ist hardening, wardrobe camera+gallery
- /api/img: add garments/ to ALLOWED_PREFIXES so garment images proxy correctly - garments upload: resolve Android empty MIME type from file extension; return /api/img proxy URLs instead of raw pub-*.r2.dev (blocked by Cloudflare Bot Mgmt) - garments route + [id] route: toDto() now builds /api/img?key= proxy URLs - date-ist.ts: add toUTCDate() helper -- strings without Z/offset treated as UTC, preventing browser local-time misinterpretation; used in fmtTime, fmtDate, dateIST - LogModal: add editTime to SmartDefault; pre-fill time picker (custom preset) when editing an existing log instead of defaulting to now - activity page: pass editTime: log.loggedAt in handleEdit so LogModal pre-fills - wardrobe/add: explicit Camera and Gallery buttons via separate hidden inputs (one with capture=environment for direct camera, one without for media picker) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
e99a874309
commit
3cfcbdc0ca
8 changed files with 124 additions and 47 deletions
|
|
@ -91,7 +91,11 @@ export default function ActivityPage() {
|
||||||
const handleEdit = (log: Log) => {
|
const handleEdit = (log: Log) => {
|
||||||
// Store the old log to delete after the new one is saved
|
// Store the old log to delete after the new one is saved
|
||||||
setPendingDeleteId({ id: log.id, type: log.type });
|
setPendingDeleteId({ id: log.id, type: log.type });
|
||||||
setSmartDefault({ subType: log.subType ?? "", amountMl: log.amount ?? undefined } as SmartDefault);
|
setSmartDefault({
|
||||||
|
subType: log.subType ?? "",
|
||||||
|
amountMl: log.amount ?? undefined,
|
||||||
|
editTime: log.loggedAt, // pre-fills the time picker with the original log time
|
||||||
|
} as SmartDefault);
|
||||||
setModalType(log.type as ModalLogType);
|
setModalType(log.type as ModalLogType);
|
||||||
setSelectedLog(null);
|
setSelectedLog(null);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -110,7 +110,8 @@ function SingleChipRow<T extends string>({
|
||||||
export default function AddGarmentPage() {
|
export default function AddGarmentPage() {
|
||||||
const { childId } = useFamily();
|
const { childId } = useFamily();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null); // gallery picker
|
||||||
|
const cameraRef = useRef<HTMLInputElement>(null); // camera capture
|
||||||
|
|
||||||
const [step, setStep] = useState<Step>("capture");
|
const [step, setStep] = useState<Step>("capture");
|
||||||
const [preview, setPreview] = useState<string | null>(null);
|
const [preview, setPreview] = useState<string | null>(null);
|
||||||
|
|
@ -251,6 +252,7 @@ export default function AddGarmentPage() {
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
setVisionPending(false);
|
setVisionPending(false);
|
||||||
if (fileRef.current) fileRef.current.value = "";
|
if (fileRef.current) fileRef.current.value = "";
|
||||||
|
if (cameraRef.current) cameraRef.current.value = "";
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -280,20 +282,30 @@ export default function AddGarmentPage() {
|
||||||
{/* Step 1: Capture */}
|
{/* Step 1: Capture */}
|
||||||
{step === "capture" && (
|
{step === "capture" && (
|
||||||
<div className="mx-4 mt-4">
|
<div className="mx-4 mt-4">
|
||||||
<div
|
<div className="aspect-square rounded-3xl border-4 border-dashed border-rose-200 dark:border-gray-600 flex flex-col items-center justify-center gap-4 bg-white dark:bg-gray-800">
|
||||||
onClick={() => fileRef.current?.click()}
|
|
||||||
className="aspect-square rounded-3xl border-4 border-dashed border-rose-200 dark:border-gray-600 flex flex-col items-center justify-center gap-4 cursor-pointer hover:border-rose-400 transition-colors bg-white dark:bg-gray-800"
|
|
||||||
>
|
|
||||||
<span className="text-6xl">👚</span>
|
<span className="text-6xl">👚</span>
|
||||||
<div className="text-center px-4">
|
<div className="text-center px-4">
|
||||||
<p className="font-semibold text-gray-700 dark:text-gray-200">Take or upload a photo</p>
|
<p className="font-semibold text-gray-700 dark:text-gray-200">Add a garment photo</p>
|
||||||
<p className="text-sm text-gray-400 mt-1">Tip: flat-lay on a light surface for best tagging</p>
|
<p className="text-sm text-gray-400 mt-1">Tip: flat-lay on a light surface for best AI tagging</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="px-4 py-2 bg-rose-400 text-white rounded-full text-sm font-medium">
|
<div className="flex gap-3">
|
||||||
📷 Add Photo
|
<button
|
||||||
</span>
|
type="button"
|
||||||
|
onClick={() => cameraRef.current?.click()}
|
||||||
|
className="flex items-center gap-2 px-5 py-2.5 bg-rose-400 text-white rounded-full text-sm font-medium shadow-sm active:scale-95 transition-transform"
|
||||||
|
>
|
||||||
|
📷 Camera
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
className="flex items-center gap-2 px-5 py-2.5 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 rounded-full text-sm font-medium shadow-sm active:scale-95 transition-transform"
|
||||||
|
>
|
||||||
|
🖼 Gallery
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/* No capture="environment" so Android shows "Camera / Gallery" chooser */}
|
</div>
|
||||||
|
{/* Gallery input — no capture attribute, Android shows full media picker */}
|
||||||
<input
|
<input
|
||||||
ref={fileRef}
|
ref={fileRef}
|
||||||
type="file"
|
type="file"
|
||||||
|
|
@ -301,6 +313,15 @@ export default function AddGarmentPage() {
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
/>
|
/>
|
||||||
|
{/* Camera input — capture="environment" opens rear camera directly */}
|
||||||
|
<input
|
||||||
|
ref={cameraRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
capture="environment"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,10 @@ import { requireFamily } from "@/lib/auth";
|
||||||
import { sql } from "@/db";
|
import { sql } from "@/db";
|
||||||
import { GARMENT_CATEGORIES } from "@/db/schema/wardrobe";
|
import { GARMENT_CATEGORIES } from "@/db/schema/wardrobe";
|
||||||
|
|
||||||
function toDto(g: Record<string, unknown>, baseUrl: string) {
|
function toDto(g: Record<string, unknown>) {
|
||||||
|
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "";
|
||||||
|
const ik = g.image_key as string | null;
|
||||||
|
const tk = g.thumb_key as string | null;
|
||||||
return {
|
return {
|
||||||
id: g.id,
|
id: g.id,
|
||||||
familyId: g.family_id,
|
familyId: g.family_id,
|
||||||
|
|
@ -14,10 +17,11 @@ function toDto(g: Record<string, unknown>, baseUrl: string) {
|
||||||
colors: g.colors || [],
|
colors: g.colors || [],
|
||||||
seasons: g.seasons || [],
|
seasons: g.seasons || [],
|
||||||
occasionTags: g.occasion_tags || [],
|
occasionTags: g.occasion_tags || [],
|
||||||
imageKey: g.image_key,
|
imageKey: ik,
|
||||||
thumbKey: g.thumb_key,
|
thumbKey: tk,
|
||||||
imageUrl: `${baseUrl}/${g.image_key}`,
|
// Proxy through /api/img — never expose raw pub-*.r2.dev URLs (blocked by Cloudflare Bot Mgmt)
|
||||||
thumbUrl: `${baseUrl}/${g.thumb_key}`,
|
imageUrl: ik ? `${appUrl}/api/img?key=${encodeURIComponent(ik)}` : null,
|
||||||
|
thumbUrl: tk ? `${appUrl}/api/img?key=${encodeURIComponent(tk)}` : null,
|
||||||
status: g.status,
|
status: g.status,
|
||||||
acquiredVia: g.acquired_via,
|
acquiredVia: g.acquired_via,
|
||||||
giftFrom: g.gift_from,
|
giftFrom: g.gift_from,
|
||||||
|
|
@ -27,10 +31,6 @@ function toDto(g: Record<string, unknown>, baseUrl: string) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBaseUrl() {
|
|
||||||
return process.env.R2_PUBLIC_URL || `https://pub-${process.env.R2_ACCOUNT_ID}.r2.dev`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET /api/garments/[id]
|
// GET /api/garments/[id]
|
||||||
export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
const auth = await requireFamily();
|
const auth = await requireFamily();
|
||||||
|
|
@ -47,7 +47,7 @@ export async function GET(_req: NextRequest, { params }: { params: Promise<{ id:
|
||||||
WHERE garment_id = ${id} AND family_id = ${familyId}
|
WHERE garment_id = ${id} AND family_id = ${familyId}
|
||||||
ORDER BY worn_on DESC`;
|
ORDER BY worn_on DESC`;
|
||||||
|
|
||||||
return NextResponse.json({ success: true, item: toDto(rows[0], getBaseUrl()), wears });
|
return NextResponse.json({ success: true, item: toDto(rows[0]), wears });
|
||||||
}
|
}
|
||||||
|
|
||||||
// PATCH /api/garments/[id]
|
// PATCH /api/garments/[id]
|
||||||
|
|
@ -92,7 +92,7 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id
|
||||||
WHERE id = ${id} AND family_id = ${familyId}
|
WHERE id = ${id} AND family_id = ${familyId}
|
||||||
RETURNING *`;
|
RETURNING *`;
|
||||||
|
|
||||||
return NextResponse.json({ success: true, item: toDto(rows[0], getBaseUrl()) });
|
return NextResponse.json({ success: true, item: toDto(rows[0]) });
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE /api/garments/[id]
|
// DELETE /api/garments/[id]
|
||||||
|
|
|
||||||
|
|
@ -70,11 +70,9 @@ export async function GET(req: NextRequest) {
|
||||||
ORDER BY created_at DESC`;
|
ORDER BY created_at DESC`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseUrl = process.env.R2_PUBLIC_URL || `https://pub-${process.env.R2_ACCOUNT_ID}.r2.dev`;
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
items: rows.map(g => toDto(g, baseUrl)),
|
items: rows.map(g => toDto(g)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -132,11 +130,13 @@ export async function POST(req: NextRequest) {
|
||||||
${visionMetadata ? JSON.stringify(visionMetadata) : null})
|
${visionMetadata ? JSON.stringify(visionMetadata) : null})
|
||||||
RETURNING *`;
|
RETURNING *`;
|
||||||
|
|
||||||
const baseUrl = process.env.R2_PUBLIC_URL || `https://pub-${process.env.R2_ACCOUNT_ID}.r2.dev`;
|
return NextResponse.json({ success: true, item: toDto(rows[0]) }, { status: 201 });
|
||||||
return NextResponse.json({ success: true, item: toDto(rows[0], baseUrl) }, { status: 201 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toDto(g: Record<string, unknown>, baseUrl: string) {
|
function toDto(g: Record<string, unknown>) {
|
||||||
|
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "";
|
||||||
|
const ik = g.image_key as string | null;
|
||||||
|
const tk = g.thumb_key as string | null;
|
||||||
return {
|
return {
|
||||||
id: g.id,
|
id: g.id,
|
||||||
familyId: g.family_id,
|
familyId: g.family_id,
|
||||||
|
|
@ -147,10 +147,11 @@ function toDto(g: Record<string, unknown>, baseUrl: string) {
|
||||||
colors: g.colors || [],
|
colors: g.colors || [],
|
||||||
seasons: g.seasons || [],
|
seasons: g.seasons || [],
|
||||||
occasionTags: g.occasion_tags || [],
|
occasionTags: g.occasion_tags || [],
|
||||||
imageKey: g.image_key,
|
imageKey: ik,
|
||||||
thumbKey: g.thumb_key,
|
thumbKey: tk,
|
||||||
imageUrl: `${baseUrl}/${g.image_key}`,
|
// Proxy through /api/img — never expose raw pub-*.r2.dev URLs (blocked by Cloudflare Bot Mgmt)
|
||||||
thumbUrl: `${baseUrl}/${g.thumb_key}`,
|
imageUrl: ik ? `${appUrl}/api/img?key=${encodeURIComponent(ik)}` : null,
|
||||||
|
thumbUrl: tk ? `${appUrl}/api/img?key=${encodeURIComponent(tk)}` : null,
|
||||||
status: g.status,
|
status: g.status,
|
||||||
acquiredVia: g.acquired_via,
|
acquiredVia: g.acquired_via,
|
||||||
giftFrom: g.gift_from,
|
giftFrom: g.gift_from,
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,17 @@ import { randomUUID } from "crypto";
|
||||||
const ALLOWED_TYPES = ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/heic"];
|
const ALLOWED_TYPES = ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/heic"];
|
||||||
const MAX_BYTES = 8 * 1024 * 1024; // 8MB
|
const MAX_BYTES = 8 * 1024 * 1024; // 8MB
|
||||||
|
|
||||||
|
/** Android often returns file.type="" or "application/octet-stream" — infer from extension. */
|
||||||
|
function resolveContentType(file: File): string {
|
||||||
|
if (file.type && file.type !== "application/octet-stream") return file.type;
|
||||||
|
const ext = file.name.split(".").pop()?.toLowerCase() || "";
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png",
|
||||||
|
webp: "image/webp", heic: "image/heic",
|
||||||
|
};
|
||||||
|
return map[ext] || "image/jpeg";
|
||||||
|
}
|
||||||
|
|
||||||
function getR2Config() {
|
function getR2Config() {
|
||||||
return {
|
return {
|
||||||
accountId: process.env.R2_ACCOUNT_ID!,
|
accountId: process.env.R2_ACCOUNT_ID!,
|
||||||
|
|
@ -50,7 +61,8 @@ export async function POST(req: NextRequest) {
|
||||||
const file = formData.get("file") as File | null;
|
const file = formData.get("file") as File | null;
|
||||||
if (!file) return NextResponse.json({ error: "No file provided" }, { status: 400 });
|
if (!file) return NextResponse.json({ error: "No file provided" }, { status: 400 });
|
||||||
|
|
||||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
const contentType = resolveContentType(file);
|
||||||
|
if (!ALLOWED_TYPES.includes(contentType)) {
|
||||||
return NextResponse.json({ error: "Unsupported file type" }, { status: 400 });
|
return NextResponse.json({ error: "Unsupported file type" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -78,7 +90,7 @@ export async function POST(req: NextRequest) {
|
||||||
Bucket: R2.bucket,
|
Bucket: R2.bucket,
|
||||||
Key: imageKey,
|
Key: imageKey,
|
||||||
Body: originalBuffer,
|
Body: originalBuffer,
|
||||||
ContentType: file.type,
|
ContentType: contentType,
|
||||||
})),
|
})),
|
||||||
client.send(new PutObjectCommand({
|
client.send(new PutObjectCommand({
|
||||||
Bucket: R2.bucket,
|
Bucket: R2.bucket,
|
||||||
|
|
@ -88,12 +100,12 @@ export async function POST(req: NextRequest) {
|
||||||
})),
|
})),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const baseUrl = R2.publicUrl || `https://pub-${R2.accountId}.r2.dev`;
|
// Return proxy URLs (never raw R2 pub URLs — those are blocked by Cloudflare Bot Management)
|
||||||
|
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "";
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
imageKey,
|
imageKey,
|
||||||
thumbKey,
|
thumbKey,
|
||||||
imageUrl: `${baseUrl}/${imageKey}`,
|
imageUrl: `${appUrl}/api/img?key=${encodeURIComponent(imageKey)}`,
|
||||||
thumbUrl: `${baseUrl}/${thumbKey}`,
|
thumbUrl: `${appUrl}/api/img?key=${encodeURIComponent(thumbKey)}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
|
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
const ALLOWED_PREFIXES = ["avatars/", "profiles/", "memories/", "thumbnails/", "families/"];
|
const ALLOWED_PREFIXES = ["avatars/", "profiles/", "memories/", "thumbnails/", "families/", "garments/"];
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
const key = req.nextUrl.searchParams.get("key");
|
const key = req.nextUrl.searchParams.get("key");
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ export type { LogType };
|
||||||
export interface SmartDefault {
|
export interface SmartDefault {
|
||||||
subType: string;
|
subType: string;
|
||||||
amountMl?: number;
|
amountMl?: number;
|
||||||
|
/** UTC ISO string of the original log time — when set, pre-fills the time picker for editing. */
|
||||||
|
editTime?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -36,6 +38,17 @@ function localDatetimeNow() {
|
||||||
return new Date(now.getTime() - offset).toISOString().slice(0, 16);
|
return new Date(now.getTime() - offset).toISOString().slice(0, 16);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a UTC ISO string to a datetime-local value in the device's local timezone.
|
||||||
|
* This is the inverse of what new Date(datetimeLocalValue).toISOString() does.
|
||||||
|
* Used to pre-fill the time picker when editing an existing log.
|
||||||
|
*/
|
||||||
|
function isoToLocalDatetimeLocal(utcIso: string): string {
|
||||||
|
const date = new Date(utcIso);
|
||||||
|
const offset = date.getTimezoneOffset() * 60000; // negative for UTC+tz (e.g. IST = -330*60000)
|
||||||
|
return new Date(date.getTime() - offset).toISOString().slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
function resolveLoggedAt(preset: TimePreset, customValue: string): string {
|
function resolveLoggedAt(preset: TimePreset, customValue: string): string {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
if (preset === "5") return new Date(now.getTime() - 5 * 60_000).toISOString();
|
if (preset === "5") return new Date(now.getTime() - 5 * 60_000).toISOString();
|
||||||
|
|
@ -59,14 +72,21 @@ export function LogModal({ type, childId, onClose, onSaved, smartDefault }: Prop
|
||||||
const [timePreset, setTimePreset] = useState<TimePreset>("now");
|
const [timePreset, setTimePreset] = useState<TimePreset>("now");
|
||||||
const [customTime, setCustomTime] = useState(localDatetimeNow);
|
const [customTime, setCustomTime] = useState(localDatetimeNow);
|
||||||
|
|
||||||
// Reset fields and apply smart defaults whenever type changes
|
// Reset fields and apply smart defaults whenever the modal opens (type changes to non-null)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!type) return;
|
if (!type) return;
|
||||||
setSubType(smartDefault?.subType ?? DEFAULT_SUBTYPE[type]);
|
setSubType(smartDefault?.subType ?? DEFAULT_SUBTYPE[type]);
|
||||||
setAmountMl(type === "feed" && smartDefault?.amountMl ? String(smartDefault.amountMl) : "");
|
setAmountMl(type === "feed" && smartDefault?.amountMl ? String(smartDefault.amountMl) : "");
|
||||||
setNotes("");
|
setNotes("");
|
||||||
|
if (smartDefault?.editTime) {
|
||||||
|
// Editing an existing log — pre-fill the picker with its original time
|
||||||
|
setTimePreset("custom");
|
||||||
|
setCustomTime(isoToLocalDatetimeLocal(smartDefault.editTime));
|
||||||
|
} else {
|
||||||
setTimePreset("now");
|
setTimePreset("now");
|
||||||
setCustomTime(localDatetimeNow());
|
setCustomTime(localDatetimeNow());
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [type]);
|
}, [type]);
|
||||||
|
|
||||||
if (!type) return null;
|
if (!type) return null;
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,25 @@
|
||||||
const TZ = "Asia/Kolkata";
|
const TZ = "Asia/Kolkata";
|
||||||
const LOCALE = "en-IN";
|
const LOCALE = "en-IN";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely convert any timestamp to a Date, treating no-timezone strings as UTC.
|
||||||
|
*
|
||||||
|
* Why: `new Date("2024-05-28T09:00:00")` (no 'Z') is treated as *local* time by
|
||||||
|
* browsers/Node, which breaks IST display when the host is in a different tz.
|
||||||
|
* `new Date("2024-05-28T09:00:00.000Z")` is always UTC regardless of host tz.
|
||||||
|
*
|
||||||
|
* Our postgres.js custom parser already appends 'Z', so DB values are always safe.
|
||||||
|
* This helper is a safety net for values from other sources (API responses, props).
|
||||||
|
*/
|
||||||
|
function toUTCDate(isoOrDate: string | Date): Date {
|
||||||
|
if (isoOrDate instanceof Date) return isoOrDate;
|
||||||
|
const s = isoOrDate as string;
|
||||||
|
// Already has explicit timezone: trailing 'Z' or '+HH:MM' / '-HH:MM' offset
|
||||||
|
if (s.endsWith("Z") || /[+-]\d{2}:\d{2}$/.test(s)) return new Date(s);
|
||||||
|
// No timezone marker — assume UTC (postgres timestamp without time zone)
|
||||||
|
return new Date(s + "Z");
|
||||||
|
}
|
||||||
|
|
||||||
/** ISO date string (YYYY-MM-DD) for today in IST — use for comparisons. */
|
/** ISO date string (YYYY-MM-DD) for today in IST — use for comparisons. */
|
||||||
export function todayIST(): string {
|
export function todayIST(): string {
|
||||||
// "sv-SE" locale gives YYYY-MM-DD — a clean, comparable format.
|
// "sv-SE" locale gives YYYY-MM-DD — a clean, comparable format.
|
||||||
|
|
@ -20,7 +39,7 @@ export function todayIST(): string {
|
||||||
|
|
||||||
/** ISO date string (YYYY-MM-DD) for any timestamp, evaluated in IST. */
|
/** ISO date string (YYYY-MM-DD) for any timestamp, evaluated in IST. */
|
||||||
export function dateIST(isoOrDate: string | Date): string {
|
export function dateIST(isoOrDate: string | Date): string {
|
||||||
return new Date(isoOrDate).toLocaleDateString("sv-SE", { timeZone: TZ });
|
return toUTCDate(isoOrDate).toLocaleDateString("sv-SE", { timeZone: TZ });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True if the given timestamp falls on today in IST. */
|
/** True if the given timestamp falls on today in IST. */
|
||||||
|
|
@ -49,7 +68,7 @@ export function hourIST(): number {
|
||||||
|
|
||||||
/** Format a timestamp as "hh:mm AM/PM" in IST. */
|
/** Format a timestamp as "hh:mm AM/PM" in IST. */
|
||||||
export function fmtTime(isoOrDate: string | Date): string {
|
export function fmtTime(isoOrDate: string | Date): string {
|
||||||
return new Date(isoOrDate).toLocaleTimeString(LOCALE, {
|
return toUTCDate(isoOrDate).toLocaleTimeString(LOCALE, {
|
||||||
timeZone: TZ,
|
timeZone: TZ,
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
|
|
@ -61,7 +80,7 @@ export function fmtDate(
|
||||||
isoOrDate: string | Date,
|
isoOrDate: string | Date,
|
||||||
opts: Intl.DateTimeFormatOptions = { day: "numeric", month: "short", year: "numeric" }
|
opts: Intl.DateTimeFormatOptions = { day: "numeric", month: "short", year: "numeric" }
|
||||||
): string {
|
): string {
|
||||||
return new Date(isoOrDate).toLocaleDateString(LOCALE, { timeZone: TZ, ...opts });
|
return toUTCDate(isoOrDate).toLocaleDateString(LOCALE, { timeZone: TZ, ...opts });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue