Compare commits
6 commits
eecedece6b
...
95304d90df
| Author | SHA1 | Date | |
|---|---|---|---|
| 95304d90df | |||
| 6d74fe1acb | |||
| cc60561c52 | |||
| db73170da1 | |||
| 11ed89fd10 | |||
| 92117374b7 |
17 changed files with 46725 additions and 45 deletions
|
|
@ -359,7 +359,7 @@ shadcn/ui components live in `src/components/ui/`. Use `bunx shadcn@latest add <
|
||||||
|
|
||||||
`bun run deploy` bumps the patch version (via `bun pm version patch`) and runs the production build. Push the resulting version commit to deploy through Coolify; the legacy `build/deploy.sh` path is no longer used.
|
`bun run deploy` bumps the patch version (via `bun pm version patch`) and runs the production build. Push the resulting version commit to deploy through Coolify; the legacy `build/deploy.sh` path is no longer used.
|
||||||
|
|
||||||
**Version bump after git commands**: whenever a `/git` or `/git-master` workflow creates commits, also bump the patch version with `bun pm version patch` and push it together with the work. The command itself creates the `vX.Y.Z` version commit **and** a local git tag, but plain `git push` does **not** push tags (`push.followTags` is not set in this repo) — push the tag explicitly with `git push && git push --tags` (or `git push --follow-tags`). The deployed site's version overlay reads `package.json`'s `version` (Coolify builds have no git metadata, so the displayed version is `<semver>+<commit sha>`), and an unbumped semver means every deploy shows the same `0.2.0`-style number even though the commit-hash suffix changes. Skipping the bump — or pushing the commit without its tag — makes releases indistinguishable and breaks version history.
|
**Version bump after git commands**: whenever a `/git` or `/git-master` workflow creates commits, also bump the patch version with `bun pm version patch` and push it together with the work. The command itself creates the `vX.Y.Z` version commit **and** a local git tag. `push.followTags` is set in this repo, so a plain `git push` automatically carries the annotated release tag with the commits — but never use a bare `git push --tags`, which sends only tags and not the branch. The deployed site's version overlay reads `package.json`'s `version` (Coolify builds have no git metadata, so the displayed version is `<semver>+<commit sha>`), and an unbumped semver means every deploy shows the same `0.2.0`-style number even though the commit-hash suffix changes. Skipping the bump — or pushing the commit without its tag — makes releases indistinguishable and breaks version history.
|
||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "polaris-task-force",
|
"name": "polaris-task-force",
|
||||||
"version": "0.2.3",
|
"version": "0.2.4",
|
||||||
"description": "Web app for the Arma 3 unit Polaris Task Force. Uses Next.js for client- and server-side rendering, with a Postgres database and some other neat libraries.",
|
"description": "Web app for the Arma 3 unit Polaris Task Force. Uses Next.js for client- and server-side rendering, with a Postgres database and some other neat libraries.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,14 @@ import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||||
import { emitGameEvent } from "@/utils/event-log/emit";
|
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||||
import { textToLexical } from "@/lib/intelligence/briefingText";
|
import { textToLexical } from "@/lib/intelligence/briefingText";
|
||||||
|
import {
|
||||||
|
nextDayKey,
|
||||||
|
parseDateKey,
|
||||||
|
wallTimeToInstant,
|
||||||
|
zonedDateKey,
|
||||||
|
zonedParts,
|
||||||
|
zonedWeekday,
|
||||||
|
} from "@/lib/intelligence/fridayTime";
|
||||||
import type { Mission } from "@/payload-types";
|
import type { Mission } from "@/payload-types";
|
||||||
|
|
||||||
export interface ActionResult<T = undefined> {
|
export interface ActionResult<T = undefined> {
|
||||||
|
|
@ -74,14 +82,18 @@ function isFridayDate(value: string): boolean {
|
||||||
if (Number.isNaN(date.getTime())) {
|
if (Number.isNaN(date.getTime())) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return date.getDay() === 5;
|
return zonedWeekday(date) === 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Side ops always start 20:00 server-local (Eastern) on the validated Friday.
|
// Side ops always start 20:00 America/New_York on the validated Friday,
|
||||||
|
// regardless of the server's own timezone (prod runs UTC).
|
||||||
function fridayStartDateTime(value: string): string {
|
function fridayStartDateTime(value: string): string {
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
date.setHours(20, 0, 0, 0);
|
if (Number.isNaN(date.getTime())) {
|
||||||
return date.toISOString();
|
return date.toISOString();
|
||||||
|
}
|
||||||
|
const p = zonedParts(date);
|
||||||
|
return wallTimeToInstant(p.year, p.month, p.day, 20, 0).toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
type SideOpMissionUser = {
|
type SideOpMissionUser = {
|
||||||
|
|
@ -317,10 +329,11 @@ async function findActiveMissionOnFriday(
|
||||||
excludeMissionId?: number,
|
excludeMissionId?: number,
|
||||||
): Promise<{ id: number; name: string; codeName: string } | null> {
|
): Promise<{ id: number; name: string; codeName: string } | null> {
|
||||||
const start = new Date(fridayStartDateTime(startDateTime));
|
const start = new Date(fridayStartDateTime(startDateTime));
|
||||||
const dayStart = new Date(start);
|
const key = zonedDateKey(start);
|
||||||
dayStart.setHours(0, 0, 0, 0);
|
const { year, month, day } = parseDateKey(key);
|
||||||
const dayEnd = new Date(dayStart);
|
const next = parseDateKey(nextDayKey(key));
|
||||||
dayEnd.setDate(dayEnd.getDate() + 1);
|
const dayStart = wallTimeToInstant(year, month, day);
|
||||||
|
const dayEnd = wallTimeToInstant(next.year, next.month, next.day);
|
||||||
|
|
||||||
const res = await payload.find({
|
const res = await payload.find({
|
||||||
collection: "missions",
|
collection: "missions",
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,13 @@ import {
|
||||||
type FridayCalendarMission,
|
type FridayCalendarMission,
|
||||||
} from "@/components/frontend/intelligence/FridayCalendar";
|
} from "@/components/frontend/intelligence/FridayCalendar";
|
||||||
import type { SideOpDialogOption } from "@/components/frontend/intelligence/CreateSideOpDialog";
|
import type { SideOpDialogOption } from "@/components/frontend/intelligence/CreateSideOpDialog";
|
||||||
|
import {
|
||||||
|
dateKeyOf,
|
||||||
|
nextDayKey,
|
||||||
|
parseDateKey,
|
||||||
|
wallTimeToInstant,
|
||||||
|
zonedDateKey,
|
||||||
|
} from "@/lib/intelligence/fridayTime";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "Friday Operations",
|
title: "Friday Operations",
|
||||||
|
|
@ -16,15 +23,16 @@ const MAX_SCHEDULED_AHEAD_MISSIONS = 2;
|
||||||
|
|
||||||
const FRIDAYS_SHOWN = 8;
|
const FRIDAYS_SHOWN = 8;
|
||||||
|
|
||||||
function nextFridays(count: number): Date[] {
|
function nextFridays(count: number): string[] {
|
||||||
const fridays: Date[] = [];
|
const fridays: string[] = [];
|
||||||
const cursor = new Date();
|
const cursor = zonedDateKey(new Date());
|
||||||
cursor.setHours(0, 0, 0, 0);
|
const [y, m, d] = cursor.split("-").map(Number);
|
||||||
|
const utc = new Date(Date.UTC(y, m - 1, d));
|
||||||
while (fridays.length < count) {
|
while (fridays.length < count) {
|
||||||
if (cursor.getDay() === 5) {
|
if (utc.getUTCDay() === 5) {
|
||||||
fridays.push(new Date(cursor));
|
fridays.push(dateKeyOf(utc.getUTCFullYear(), utc.getUTCMonth() + 1, utc.getUTCDate()));
|
||||||
}
|
}
|
||||||
cursor.setDate(cursor.getDate() + 1);
|
utc.setUTCDate(utc.getUTCDate() + 1);
|
||||||
}
|
}
|
||||||
return fridays;
|
return fridays;
|
||||||
}
|
}
|
||||||
|
|
@ -38,9 +46,10 @@ export default async function FridayCalendarPage() {
|
||||||
});
|
});
|
||||||
|
|
||||||
const fridays = nextFridays(FRIDAYS_SHOWN);
|
const fridays = nextFridays(FRIDAYS_SHOWN);
|
||||||
const rangeStart = fridays[0];
|
const first = parseDateKey(fridays[0]);
|
||||||
const rangeEnd = new Date(fridays[fridays.length - 1]);
|
const last = parseDateKey(nextDayKey(fridays[fridays.length - 1]));
|
||||||
rangeEnd.setDate(rangeEnd.getDate() + 1);
|
const rangeStart = wallTimeToInstant(first.year, first.month, first.day);
|
||||||
|
const rangeEnd = wallTimeToInstant(last.year, last.month, last.day);
|
||||||
|
|
||||||
const missionsRes = await payload.find({
|
const missionsRes = await payload.find({
|
||||||
collection: "missions",
|
collection: "missions",
|
||||||
|
|
@ -63,15 +72,9 @@ export default async function FridayCalendarPage() {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const entries: FridayCalendarEntry[] = fridays.map((friday) => {
|
const entries: FridayCalendarEntry[] = fridays.map((fridayKey) => {
|
||||||
const dayStart = new Date(friday);
|
|
||||||
const dayEnd = new Date(friday);
|
|
||||||
dayEnd.setDate(dayEnd.getDate() + 1);
|
|
||||||
const missions: FridayCalendarMission[] = missionsRes.docs
|
const missions: FridayCalendarMission[] = missionsRes.docs
|
||||||
.filter((m) => {
|
.filter((m) => zonedDateKey(new Date(m.classification.startDateTime)) === fridayKey)
|
||||||
const start = new Date(m.classification.startDateTime);
|
|
||||||
return start >= dayStart && start < dayEnd;
|
|
||||||
})
|
|
||||||
.map((m) => {
|
.map((m) => {
|
||||||
const map = m.classification.map;
|
const map = m.classification.map;
|
||||||
const createdBy = m.createdBy;
|
const createdBy = m.createdBy;
|
||||||
|
|
@ -88,7 +91,12 @@ export default async function FridayCalendarPage() {
|
||||||
(typeof createdBy === "object" ? createdBy?.id : createdBy) === user.id,
|
(typeof createdBy === "object" ? createdBy?.id : createdBy) === user.id,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
return { fridayIso: friday.toISOString(), missions };
|
const k = parseDateKey(fridayKey);
|
||||||
|
return {
|
||||||
|
fridayIso: fridayKey,
|
||||||
|
startIso: wallTimeToInstant(k.year, k.month, k.day, 20, 0).toISOString(),
|
||||||
|
missions,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
let remainingSlots = 0;
|
let remainingSlots = 0;
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,9 @@ import { getPayload } from "payload";
|
||||||
import { headers as nextHeaders } from "next/headers";
|
import { headers as nextHeaders } from "next/headers";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound, redirect } from "next/navigation";
|
import { notFound, redirect } from "next/navigation";
|
||||||
import { ArrowLeftIcon, MicIcon } from "lucide-react";
|
import { ArrowLeftIcon, MicIcon, TriangleAlertIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { VoiceoverRecorder } from "@/components/frontend/voiceover/VoiceoverRecorder";
|
import { VoiceoverRecorder } from "@/components/frontend/voiceover/VoiceoverRecorder";
|
||||||
import { getScriptForUser } from "@/lib/voiceover";
|
import { getScriptForUser } from "@/lib/voiceover";
|
||||||
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||||
|
|
@ -50,12 +51,30 @@ export default async function VoiceoverScriptPage({
|
||||||
<MicIcon className="mt-0.5 size-5 shrink-0 text-muted-foreground" />
|
<MicIcon className="mt-0.5 size-5 shrink-0 text-muted-foreground" />
|
||||||
<div className="flex min-w-0 flex-col gap-1">
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
<h1 className="text-lg font-semibold">{script.title}</h1>
|
<h1 className="text-lg font-semibold">{script.title}</h1>
|
||||||
|
{script.description?.trim() && (
|
||||||
|
<p className="text-sm text-foreground/80">{script.description}</p>
|
||||||
|
)}
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Read the script at your pace, then submit one clear take for review.
|
Read the script at your pace, then submit one clear take for review.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{script.spoiler && (
|
||||||
|
<Card className="border-amber-500/40">
|
||||||
|
<CardContent className="flex items-start gap-3 p-4 text-sm">
|
||||||
|
<TriangleAlertIcon className="mt-0.5 size-4 shrink-0 text-amber-400" />
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="font-medium text-amber-300">Potential campaign spoilers ahead</p>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
This script may include, infer, or otherwise expose campaign spoilers. Read on only
|
||||||
|
if you are comfortable with that.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
<VoiceoverRecorder script={script} />
|
<VoiceoverRecorder script={script} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,14 @@ import { headers as nextHeaders } from "next/headers";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getPayload } from "payload";
|
import { getPayload } from "payload";
|
||||||
import config from "@payload-config";
|
import config from "@payload-config";
|
||||||
import { ArrowRightIcon, CheckCircle2Icon, Clock3Icon, MicIcon, SparklesIcon } from "lucide-react";
|
import {
|
||||||
|
ArrowRightIcon,
|
||||||
|
CheckCircle2Icon,
|
||||||
|
Clock3Icon,
|
||||||
|
MicIcon,
|
||||||
|
SparklesIcon,
|
||||||
|
TriangleAlertIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -64,6 +71,15 @@ function SubmissionStatuses({ script }: { readonly script: VoiceoverScriptView }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SpoilerBadge() {
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className="gap-1 border-amber-500/40 text-amber-300">
|
||||||
|
<TriangleAlertIcon className="size-3" />
|
||||||
|
Spoilers
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ScriptCard({ script }: { readonly script: VoiceoverScriptView }) {
|
function ScriptCard({ script }: { readonly script: VoiceoverScriptView }) {
|
||||||
const isOpen = script.status === "open";
|
const isOpen = script.status === "open";
|
||||||
|
|
||||||
|
|
@ -84,10 +100,15 @@ function ScriptCard({ script }: { readonly script: VoiceoverScriptView }) {
|
||||||
script.title
|
script.title
|
||||||
)}
|
)}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>Voiceover script #{script.id}</CardDescription>
|
<CardDescription>
|
||||||
|
{script.description?.trim() || `Voiceover script #${script.id}`}
|
||||||
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||||
|
{script.spoiler && <SpoilerBadge />}
|
||||||
<ScriptAvailability status={script.status} />
|
<ScriptAvailability status={script.status} />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Badge variant="outline" className="gap-1 border-amber-500/40 text-amber-300">
|
<Badge variant="outline" className="gap-1 border-amber-500/40 text-amber-300">
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ export const VoiceScripts: CollectionConfig = {
|
||||||
admin: {
|
admin: {
|
||||||
group: "Voiceover",
|
group: "Voiceover",
|
||||||
useAsTitle: "title",
|
useAsTitle: "title",
|
||||||
defaultColumns: ["title", "xpAmount", "status", "createdAt"],
|
defaultColumns: ["title", "xpAmount", "spoiler", "status", "createdAt"],
|
||||||
},
|
},
|
||||||
access: {
|
access: {
|
||||||
read: ({ req }) => Boolean(req.user),
|
read: ({ req }) => Boolean(req.user),
|
||||||
|
|
@ -20,6 +20,15 @@ export const VoiceScripts: CollectionConfig = {
|
||||||
type: "text",
|
type: "text",
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "description",
|
||||||
|
label: "Context Description",
|
||||||
|
type: "textarea",
|
||||||
|
admin: {
|
||||||
|
description:
|
||||||
|
"Optional context for readers: who is speaking, where this line happens, and anything else they should know before recording.",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "script",
|
name: "script",
|
||||||
type: "textarea",
|
type: "textarea",
|
||||||
|
|
@ -40,6 +49,16 @@ export const VoiceScripts: CollectionConfig = {
|
||||||
description: "XP awarded per accepted submission of this script.",
|
description: "XP awarded per accepted submission of this script.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "spoiler",
|
||||||
|
label: "Potential Campaign Spoilers",
|
||||||
|
type: "checkbox",
|
||||||
|
defaultValue: false,
|
||||||
|
admin: {
|
||||||
|
description:
|
||||||
|
"Warns readers on the frontend that this script may include, infer, or expose campaign spoilers.",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "status",
|
name: "status",
|
||||||
type: "select",
|
type: "select",
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,15 @@ export const VoiceSubmissions: CollectionConfig = {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
upload: {
|
upload: {
|
||||||
mimeTypes: ["audio/webm", "audio/mpeg", "audio/wav", "audio/ogg", "audio/mp4", "audio/x-m4a", "audio/m4a"],
|
mimeTypes: [
|
||||||
|
"audio/webm",
|
||||||
|
"audio/mpeg",
|
||||||
|
"audio/wav",
|
||||||
|
"audio/ogg",
|
||||||
|
"audio/mp4",
|
||||||
|
"audio/x-m4a",
|
||||||
|
"audio/m4a",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
timestamps: true,
|
timestamps: true,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { CalendarDaysIcon, ChevronRightIcon, PlusIcon } from "lucide-react";
|
||||||
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { CreateSideOpDialog, type SideOpDialogOption } from "./CreateSideOpDialog";
|
import { CreateSideOpDialog, type SideOpDialogOption } from "./CreateSideOpDialog";
|
||||||
|
import { parseDateKey } from "@/lib/intelligence/fridayTime";
|
||||||
|
|
||||||
export interface FridayCalendarMission {
|
export interface FridayCalendarMission {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -21,6 +22,7 @@ export interface FridayCalendarMission {
|
||||||
|
|
||||||
export interface FridayCalendarEntry {
|
export interface FridayCalendarEntry {
|
||||||
fridayIso: string;
|
fridayIso: string;
|
||||||
|
startIso: string;
|
||||||
missions: FridayCalendarMission[];
|
missions: FridayCalendarMission[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -44,7 +46,8 @@ const statusColors: Record<string, string> = {
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatFriday(iso: string): string {
|
function formatFriday(iso: string): string {
|
||||||
return new Date(iso).toLocaleDateString(undefined, {
|
const { year, month, day } = parseDateKey(iso);
|
||||||
|
return new Date(year, month - 1, day).toLocaleDateString(undefined, {
|
||||||
weekday: "long",
|
weekday: "long",
|
||||||
month: "long",
|
month: "long",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
|
|
@ -58,12 +61,6 @@ function formatTime(iso: string): string {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultStartForFriday(fridayIso: string): string {
|
|
||||||
const date = new Date(fridayIso);
|
|
||||||
date.setHours(20, 0, 0, 0);
|
|
||||||
return date.toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FridayCalendar({
|
export function FridayCalendar({
|
||||||
entries,
|
entries,
|
||||||
remainingSlots,
|
remainingSlots,
|
||||||
|
|
@ -119,7 +116,7 @@ export function FridayCalendar({
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={!canSchedule}
|
disabled={!canSchedule}
|
||||||
onClick={() => setScheduleFor(entry.fridayIso)}
|
onClick={() => setScheduleFor(entry.startIso)}
|
||||||
>
|
>
|
||||||
<PlusIcon className="size-3.5" />
|
<PlusIcon className="size-3.5" />
|
||||||
{fridayTaken ? "Taken" : "Schedule"}
|
{fridayTaken ? "Taken" : "Schedule"}
|
||||||
|
|
@ -182,7 +179,7 @@ export function FridayCalendar({
|
||||||
maps={maps}
|
maps={maps}
|
||||||
campaigns={campaigns}
|
campaigns={campaigns}
|
||||||
modLists={modLists}
|
modLists={modLists}
|
||||||
defaultStartDateTime={scheduleFor ? defaultStartForFriday(scheduleFor) : undefined}
|
defaultStartDateTime={scheduleFor ?? undefined}
|
||||||
onSaved={() => router.refresh()}
|
onSaved={() => router.refresh()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
81
src/lib/intelligence/fridayTime.ts
Normal file
81
src/lib/intelligence/fridayTime.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
export const UNIT_TIME_ZONE = "America/New_York";
|
||||||
|
|
||||||
|
export interface ZonedParts {
|
||||||
|
year: number;
|
||||||
|
month: number;
|
||||||
|
day: number;
|
||||||
|
hour: number;
|
||||||
|
minute: number;
|
||||||
|
second: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function zonedParts(date: Date, timeZone: string = UNIT_TIME_ZONE): ZonedParts {
|
||||||
|
const parts = new Intl.DateTimeFormat("en-US", {
|
||||||
|
timeZone,
|
||||||
|
hour12: false,
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
}).formatToParts(date);
|
||||||
|
const get = (type: string) => Number(parts.find((p) => p.type === type)?.value ?? 0);
|
||||||
|
return {
|
||||||
|
year: get("year"),
|
||||||
|
month: get("month"),
|
||||||
|
day: get("day"),
|
||||||
|
hour: get("hour") % 24,
|
||||||
|
minute: get("minute"),
|
||||||
|
second: get("second"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad2(n: number): string {
|
||||||
|
return String(n).padStart(2, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dateKeyOf(year: number, month: number, day: number): string {
|
||||||
|
return `${String(year).padStart(4, "0")}-${pad2(month)}-${pad2(day)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function zonedDateKey(date: Date, timeZone: string = UNIT_TIME_ZONE): string {
|
||||||
|
const p = zonedParts(date, timeZone);
|
||||||
|
return dateKeyOf(p.year, p.month, p.day);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tzOffsetMs(date: Date, timeZone: string): number {
|
||||||
|
const p = zonedParts(date, timeZone);
|
||||||
|
const asUtc = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second);
|
||||||
|
return asUtc - date.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wallTimeToInstant(
|
||||||
|
year: number,
|
||||||
|
month: number,
|
||||||
|
day: number,
|
||||||
|
hour = 0,
|
||||||
|
minute = 0,
|
||||||
|
timeZone: string = UNIT_TIME_ZONE,
|
||||||
|
): Date {
|
||||||
|
const guess = Date.UTC(year, month - 1, day, hour, minute);
|
||||||
|
let ts = guess - tzOffsetMs(new Date(guess), timeZone);
|
||||||
|
ts = guess - tzOffsetMs(new Date(ts), timeZone);
|
||||||
|
return new Date(ts);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nextDayKey(key: string): string {
|
||||||
|
const [y, m, d] = key.split("-").map(Number);
|
||||||
|
const next = new Date(Date.UTC(y, m - 1, d + 1));
|
||||||
|
return dateKeyOf(next.getUTCFullYear(), next.getUTCMonth() + 1, next.getUTCDate());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function zonedWeekday(date: Date, timeZone: string = UNIT_TIME_ZONE): number {
|
||||||
|
const p = zonedParts(date, timeZone);
|
||||||
|
return new Date(Date.UTC(p.year, p.month - 1, p.day)).getUTCDay();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseDateKey(key: string): { year: number; month: number; day: number } {
|
||||||
|
const [y, m, d] = key.split("-").map(Number);
|
||||||
|
return { year: y, month: m, day: d };
|
||||||
|
}
|
||||||
|
|
@ -12,9 +12,11 @@ export interface VoiceoverSubmissionSummary {
|
||||||
export interface VoiceoverScriptView {
|
export interface VoiceoverScriptView {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
|
description: string | null;
|
||||||
script: string;
|
script: string;
|
||||||
xpAmount: number;
|
xpAmount: number;
|
||||||
status: string;
|
status: string;
|
||||||
|
spoiler: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
/** This user's own submissions on the script, newest first. */
|
/** This user's own submissions on the script, newest first. */
|
||||||
mySubmissions: VoiceoverSubmissionSummary[];
|
mySubmissions: VoiceoverSubmissionSummary[];
|
||||||
|
|
@ -59,9 +61,11 @@ function buildScriptView(
|
||||||
return {
|
return {
|
||||||
id: script.id,
|
id: script.id,
|
||||||
title: script.title,
|
title: script.title,
|
||||||
|
description: script.description ?? null,
|
||||||
script: script.script,
|
script: script.script,
|
||||||
xpAmount: script.xpAmount,
|
xpAmount: script.xpAmount,
|
||||||
status: script.status,
|
status: script.status,
|
||||||
|
spoiler: script.spoiler ?? false,
|
||||||
createdAt: script.createdAt,
|
createdAt: script.createdAt,
|
||||||
mySubmissions,
|
mySubmissions,
|
||||||
otherSubmissionCount,
|
otherSubmissionCount,
|
||||||
|
|
|
||||||
23230
src/migrations/20260907_041051_add_voice_script_spoiler.json
Normal file
23230
src/migrations/20260907_041051_add_voice_script_spoiler.json
Normal file
File diff suppressed because it is too large
Load diff
11
src/migrations/20260907_041051_add_voice_script_spoiler.ts
Normal file
11
src/migrations/20260907_041051_add_voice_script_spoiler.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||||
|
|
||||||
|
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "voice_scripts" ADD COLUMN "spoiler" boolean DEFAULT false;`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "voice_scripts" DROP COLUMN "spoiler";`)
|
||||||
|
}
|
||||||
23236
src/migrations/20260907_043339_add_voice_script_description.json
Normal file
23236
src/migrations/20260907_043339_add_voice_script_description.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||||
|
|
||||||
|
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "voice_scripts" ADD COLUMN "description" varchar;`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "voice_scripts" DROP COLUMN "description";`)
|
||||||
|
}
|
||||||
|
|
@ -37,6 +37,8 @@ import * as migration_20260906_165837_add_market_state_event_target from './2026
|
||||||
import * as migration_20260906_202925_add_voice_submissions from './20260906_202925_add_voice_submissions';
|
import * as migration_20260906_202925_add_voice_submissions from './20260906_202925_add_voice_submissions';
|
||||||
import * as migration_20260907_012326_add_missions_created_by from './20260907_012326_add_missions_created_by';
|
import * as migration_20260907_012326_add_missions_created_by from './20260907_012326_add_missions_created_by';
|
||||||
import * as migration_20260907_013724_make_missions_campaign_optional from './20260907_013724_make_missions_campaign_optional';
|
import * as migration_20260907_013724_make_missions_campaign_optional from './20260907_013724_make_missions_campaign_optional';
|
||||||
|
import * as migration_20260907_041051_add_voice_script_spoiler from './20260907_041051_add_voice_script_spoiler';
|
||||||
|
import * as migration_20260907_043339_add_voice_script_description from './20260907_043339_add_voice_script_description';
|
||||||
|
|
||||||
export const migrations = [
|
export const migrations = [
|
||||||
{
|
{
|
||||||
|
|
@ -232,6 +234,16 @@ export const migrations = [
|
||||||
{
|
{
|
||||||
up: migration_20260907_013724_make_missions_campaign_optional.up,
|
up: migration_20260907_013724_make_missions_campaign_optional.up,
|
||||||
down: migration_20260907_013724_make_missions_campaign_optional.down,
|
down: migration_20260907_013724_make_missions_campaign_optional.down,
|
||||||
name: '20260907_013724_make_missions_campaign_optional'
|
name: '20260907_013724_make_missions_campaign_optional',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
up: migration_20260907_041051_add_voice_script_spoiler.up,
|
||||||
|
down: migration_20260907_041051_add_voice_script_spoiler.down,
|
||||||
|
name: '20260907_041051_add_voice_script_spoiler',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
up: migration_20260907_043339_add_voice_script_description.up,
|
||||||
|
down: migration_20260907_043339_add_voice_script_description.down,
|
||||||
|
name: '20260907_043339_add_voice_script_description'
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -3092,6 +3092,10 @@ export interface MarketState {
|
||||||
export interface VoiceScript {
|
export interface VoiceScript {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
|
/**
|
||||||
|
* Optional context for readers: who is speaking, where this line happens, and anything else they should know before recording.
|
||||||
|
*/
|
||||||
|
description?: string | null;
|
||||||
/**
|
/**
|
||||||
* Voice talent reads this verbatim. Inline emotion tags like [calm] are stored as written.
|
* Voice talent reads this verbatim. Inline emotion tags like [calm] are stored as written.
|
||||||
*/
|
*/
|
||||||
|
|
@ -3100,6 +3104,10 @@ export interface VoiceScript {
|
||||||
* XP awarded per accepted submission of this script.
|
* XP awarded per accepted submission of this script.
|
||||||
*/
|
*/
|
||||||
xpAmount: number;
|
xpAmount: number;
|
||||||
|
/**
|
||||||
|
* Warns readers on the frontend that this script may include, infer, or expose campaign spoilers.
|
||||||
|
*/
|
||||||
|
spoiler?: boolean | null;
|
||||||
/**
|
/**
|
||||||
* Closed scripts reject new submissions
|
* Closed scripts reject new submissions
|
||||||
*/
|
*/
|
||||||
|
|
@ -5207,8 +5215,10 @@ export interface MarketStateSelect<T extends boolean = true> {
|
||||||
*/
|
*/
|
||||||
export interface VoiceScriptsSelect<T extends boolean = true> {
|
export interface VoiceScriptsSelect<T extends boolean = true> {
|
||||||
title?: T;
|
title?: T;
|
||||||
|
description?: T;
|
||||||
script?: T;
|
script?: T;
|
||||||
xpAmount?: T;
|
xpAmount?: T;
|
||||||
|
spoiler?: T;
|
||||||
status?: T;
|
status?: T;
|
||||||
updatedAt?: T;
|
updatedAt?: T;
|
||||||
createdAt?: T;
|
createdAt?: T;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue