feat: the redesigned Event Clock
The new reader app replaces the old UI: Checklist with a rail (focus pills, Next to expire, today's dailies, pinned routines), Timeline in a new and a classic look, Routines, Archive and a Settings area, in a Structured (default) and a Relaxed interface. It follows the approved Round 6 B and Round 7 mockups, corrected screen by screen with the owner. Reader data moves to a v2 store (gacha-tracker:v2:*). Existing v1 data is migrated on first load, a recovery copy is written first, and the v1 keys are never removed, so a rollback finds them unchanged. Redeem codes for Genshin arrive as their own feed. Also: live's urgency ramp, editable per theme; completion and section-complete fireworks; a returning-user tour; the design mockups, specs, plans, reviews and handoff record. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
ddd9673f86
commit
50c6e91b00
@@ -0,0 +1,24 @@
|
||||
import { z } from "zod";
|
||||
import { GameId } from "./schema.ts";
|
||||
import { SourceHealth } from "./feed.ts";
|
||||
export const RedeemCodeSchema = z.object({
|
||||
id: z.string(),
|
||||
game: GameId,
|
||||
code: z.string().min(1).max(100),
|
||||
reward: z.string().max(1000).nullable(),
|
||||
expiresAt: z.string().datetime().nullable(),
|
||||
expiresOn: z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||
.nullable()
|
||||
.optional(),
|
||||
firstObservedAt: z.string().datetime(),
|
||||
sourceUrl: z.string().url(),
|
||||
});
|
||||
export type RedeemCode = z.infer<typeof RedeemCodeSchema>;
|
||||
export const CodesFeedSchema = z.object({
|
||||
schemaVersion: z.literal(1),
|
||||
codes: z.array(RedeemCodeSchema),
|
||||
sources: z.array(SourceHealth),
|
||||
});
|
||||
export type CodesFeed = z.infer<typeof CodesFeedSchema>;
|
||||
+19
-15
@@ -1,3 +1,4 @@
|
||||
import type { ResetTiming } from "./time.ts";
|
||||
import type { LaneId } from "./custom.ts";
|
||||
import type { GachaEvent, GameId, Region } from "./schema.ts";
|
||||
import { DAY, resetShiftMs } from "./time.ts";
|
||||
@@ -117,19 +118,19 @@ export function dailyOverride(
|
||||
* generic "what day is it here?" — still gets the regional answer. Anything
|
||||
* that reads or writes a tick should pass it.
|
||||
*/
|
||||
export function dayKey(ms: number, region: Region, game?: LaneId): string {
|
||||
return new Date(ms + resetShiftMs(region, game)).toISOString().slice(0, 10);
|
||||
export function dayKey(ms: number, region: Region, game?: LaneId, timing?: ResetTiming): string {
|
||||
return new Date(ms + resetShiftMs(region, game, timing)).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** The next reset instant strictly after `ms`. */
|
||||
export function nextResetMs(ms: number, region: Region, game?: LaneId): number {
|
||||
const s = resetShiftMs(region, game);
|
||||
export function nextResetMs(ms: number, region: Region, game?: LaneId, timing?: ResetTiming): number {
|
||||
const s = resetShiftMs(region, game, timing);
|
||||
return Math.floor((ms + s) / DAY) * DAY + DAY - s;
|
||||
}
|
||||
|
||||
/** How long the reader has left to do today's dailies. */
|
||||
export function msUntilReset(ms: number, region: Region, game?: LaneId): number {
|
||||
return nextResetMs(ms, region, game) - ms;
|
||||
export function msUntilReset(ms: number, region: Region, game?: LaneId, timing?: ResetTiming): number {
|
||||
return nextResetMs(ms, region, game, timing) - ms;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -144,6 +145,7 @@ export function dailyDays(
|
||||
endsMs: number | null,
|
||||
region: Region,
|
||||
game?: LaneId,
|
||||
timing?: ResetTiming,
|
||||
): string[] | null {
|
||||
if (endsMs === null) return null;
|
||||
|
||||
@@ -152,11 +154,11 @@ export function dailyDays(
|
||||
// reset: an event ending at 04:00 gives you nothing on that final day.
|
||||
const last = endsMs - 1;
|
||||
let cursor = startsMs;
|
||||
if (last < startsMs) return [dayKey(startsMs, region, game)];
|
||||
if (last < startsMs) return [dayKey(startsMs, region, game, timing)];
|
||||
|
||||
while (cursor <= last && out.length < MAX_DAYS) {
|
||||
out.push(dayKey(cursor, region, game));
|
||||
cursor = nextResetMs(cursor, region, game);
|
||||
out.push(dayKey(cursor, region, game, timing));
|
||||
cursor = nextResetMs(cursor, region, game, timing);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -196,9 +198,10 @@ export function catchUpDays(
|
||||
game: LaneId | undefined,
|
||||
notBefore: number | null,
|
||||
span = CATCH_UP_DAYS,
|
||||
timing?: ResetTiming,
|
||||
): string[] {
|
||||
const today = dayKey(now, region, game);
|
||||
const floor = notBefore === null ? null : dayKey(notBefore, region, game);
|
||||
const today = dayKey(now, region, game, timing);
|
||||
const floor = notBefore === null ? null : dayKey(notBefore, region, game, timing);
|
||||
|
||||
const out: string[] = [];
|
||||
// Walked in day-key space rather than in instants, like `streakOf`: these are
|
||||
@@ -248,10 +251,11 @@ export function dailySummary(input: {
|
||||
game?: LaneId | undefined;
|
||||
now: number;
|
||||
logged: readonly string[];
|
||||
timing?: ResetTiming;
|
||||
}): DailySummary {
|
||||
const { startsMs, endsMs, region, game, now, logged } = input;
|
||||
const days = dailyDays(startsMs, endsMs, region, game);
|
||||
const today = dayKey(now, region, game);
|
||||
const { startsMs, endsMs, region, game, now, logged, timing } = input;
|
||||
const days = dailyDays(startsMs, endsMs, region, game, timing);
|
||||
const today = dayKey(now, region, game, timing);
|
||||
const ticked = new Set(logged);
|
||||
|
||||
const inWindow = days === null ? logged.slice() : days.filter((d) => ticked.has(d));
|
||||
@@ -269,7 +273,7 @@ export function dailySummary(input: {
|
||||
? null
|
||||
: days.filter((d) => d < today && !ticked.has(d)).length,
|
||||
streak: streakOf(logged, today),
|
||||
msUntilReset: msUntilReset(now, region, game),
|
||||
msUntilReset: msUntilReset(now, region, game, timing),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+12
-8
@@ -1,3 +1,4 @@
|
||||
import type { ResetTiming } from "./time.ts";
|
||||
import { z } from "zod";
|
||||
import type { LaneId } from "./custom.ts";
|
||||
import { dailyDays, dayKey } from "./daily.ts";
|
||||
@@ -73,7 +74,7 @@ export function readGoal(stored: unknown): Goal | undefined {
|
||||
|
||||
export interface GoalStatus {
|
||||
/** Month and cycle are a task's period (task.ts); an event's goal is a week or the event. */
|
||||
scope: "week" | "month" | "cycle" | "event";
|
||||
scope: "day" | "week" | "month" | "cycle" | "event";
|
||||
measure: "days" | "amount";
|
||||
/** The period's first game-day: the key an amount is stored under. */
|
||||
periodStart: string;
|
||||
@@ -114,11 +115,12 @@ export function goalStatus(input: {
|
||||
logged: readonly string[];
|
||||
/** This item's entered numbers, for an amount goal. */
|
||||
amounts?: AmountLog | undefined;
|
||||
timing?: ResetTiming;
|
||||
}): GoalStatus {
|
||||
const { goal, startsMs, endsMs, region, game, now, logged, amounts } = input;
|
||||
const today = dayKey(now, region, game);
|
||||
const first = dayKey(startsMs, region, game);
|
||||
const run = dailyDays(startsMs, endsMs, region, game);
|
||||
const { goal, startsMs, endsMs, region, game, now, logged, amounts, timing } = input;
|
||||
const today = dayKey(now, region, game, timing);
|
||||
const first = dayKey(startsMs, region, game, timing);
|
||||
const run = dailyDays(startsMs, endsMs, region, game, timing);
|
||||
|
||||
let scope: string[];
|
||||
let periodStart: string;
|
||||
@@ -174,9 +176,11 @@ export function goalStatus(input: {
|
||||
const days = (n: number) => (n === 1 ? "1 day" : `${n} days`);
|
||||
/** Thousands grouped: 2,100 reads as a number of points, 2100 as a year. */
|
||||
const num = (n: number) => n.toLocaleString("en-US");
|
||||
/** " this week", " this month", " this cycle", or nothing for a whole event. */
|
||||
const within = (s: GoalStatus) => (s.scope === "event" ? "" : ` this ${s.scope}`);
|
||||
const doneLabel = (s: GoalStatus) => (s.scope === "event" ? "goal reached" : `done this ${s.scope}`);
|
||||
/** " today", " this week", " this month", " this cycle", or nothing for a whole event. */
|
||||
const within = (s: GoalStatus) =>
|
||||
s.scope === "event" ? "" : s.scope === "day" ? " today" : ` this ${s.scope}`;
|
||||
const doneLabel = (s: GoalStatus) =>
|
||||
s.scope === "event" ? "goal reached" : s.scope === "day" ? "done today" : `done this ${s.scope}`;
|
||||
|
||||
/**
|
||||
* The checklist's line. An unticked past day is not "missed" here: every day
|
||||
|
||||
@@ -31,6 +31,8 @@ const DayKey = z
|
||||
});
|
||||
|
||||
export const PeriodSchema = z.union([
|
||||
/** Every game-day: a reader's own daily, rolling on its reset. */
|
||||
z.object({ unit: z.literal("day") }),
|
||||
z.object({ unit: z.literal("week"), startsOn: WeekdaySchema }),
|
||||
/** Day of month; 31 means "the last day" in a shorter month. */
|
||||
z.object({ unit: z.literal("month"), startsOn: z.number().int().min(1).max(31) }),
|
||||
@@ -73,7 +75,10 @@ export function periodOf(
|
||||
): { start: string; days: string[] } {
|
||||
let start: string;
|
||||
let next: string;
|
||||
if (period.unit === "week") {
|
||||
if (period.unit === "day") {
|
||||
start = today;
|
||||
next = addDays(today, 1);
|
||||
} else if (period.unit === "week") {
|
||||
start = addDays(today, -((isoWeekday(today) - period.startsOn + 7) % 7));
|
||||
next = addDays(start, 7);
|
||||
} else if (period.unit === "cycle") {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { Account, ClockRevision } from "../client/state/reader/schema.ts";
|
||||
import type { Region } from "./schema.ts";
|
||||
import {
|
||||
clockFor,
|
||||
resetHourFor,
|
||||
serverOffsetUtc,
|
||||
type Clockable,
|
||||
type ResetTiming,
|
||||
} from "./time.ts";
|
||||
import { dayKey } from "./daily.ts";
|
||||
export interface ReaderClock extends ResetTiming {
|
||||
revision: string;
|
||||
region: Region;
|
||||
}
|
||||
export function resolveReaderClock(account: Account, at: number): ReaderClock {
|
||||
const ordered = account.clocks
|
||||
.filter((r) => r.historicalOnly !== true)
|
||||
.slice()
|
||||
.sort((a, b) => a.effectiveAt.localeCompare(b.effectiveAt));
|
||||
const r =
|
||||
ordered.filter((r) => Date.parse(r.effectiveAt) <= at).at(-1) ??
|
||||
ordered[0]!;
|
||||
return clockFromRevision(r);
|
||||
}
|
||||
export function clockFromRevision(r: ClockRevision): ReaderClock {
|
||||
return {
|
||||
revision: r.id,
|
||||
region: r.region,
|
||||
offsetUtc: r.automaticBase.offsetUtc,
|
||||
resetHour: r.reset?.hour ?? r.automaticBase.hour,
|
||||
resetMinute: r.reset?.minute ?? r.automaticBase.minute,
|
||||
};
|
||||
}
|
||||
export function appendClockRevision(
|
||||
account: Account,
|
||||
reset: ClockRevision["reset"],
|
||||
region: Region,
|
||||
at: string,
|
||||
id: string,
|
||||
): Account {
|
||||
if (
|
||||
!Number.isFinite(Date.parse(at)) ||
|
||||
account.clocks.some((r) => r.id === id) ||
|
||||
(reset &&
|
||||
(reset.hour < 0 ||
|
||||
reset.hour > 23 ||
|
||||
reset.minute < 0 ||
|
||||
reset.minute > 59 ||
|
||||
!Number.isInteger(reset.hour) ||
|
||||
!Number.isInteger(reset.minute)))
|
||||
)
|
||||
throw new Error("Invalid clock revision");
|
||||
return {
|
||||
...account,
|
||||
region,
|
||||
clocks: [
|
||||
...account.clocks,
|
||||
{
|
||||
id,
|
||||
effectiveAt: at,
|
||||
region,
|
||||
reset,
|
||||
automaticBase: {
|
||||
offsetUtc: serverOffsetUtc(region, account.game),
|
||||
hour: resetHourFor(region, account.game),
|
||||
minute: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
export function readerDayKey(at: number, clock: ReaderClock): string {
|
||||
return dayKey(at, clock.region, undefined, clock);
|
||||
}
|
||||
export function readerClockFor(
|
||||
event: Clockable,
|
||||
account: Account,
|
||||
now: number,
|
||||
) {
|
||||
const clock = resolveReaderClock(account, now);
|
||||
return clockFor(event, clock.region, now, clock);
|
||||
}
|
||||
+20
-2
@@ -1,3 +1,4 @@
|
||||
import type { ResetTiming } from "./time.ts";
|
||||
import { z } from "zod";
|
||||
import type { LaneId } from "./custom.ts";
|
||||
import { dayKey } from "./daily.ts";
|
||||
@@ -55,6 +56,20 @@ export const TaskSchema = z.object({
|
||||
measure: MeasureSchema,
|
||||
/** The built-in routine this was copied from, when it was (phase 2b). */
|
||||
from: z.string().optional(),
|
||||
/**
|
||||
* Its own reset hour, in server time, instead of the game's. Moves only this
|
||||
* routine's day boundary; its log stays under the account's clock revision.
|
||||
*/
|
||||
resetHour: z.number().int().min(0).max(23).optional(),
|
||||
note: z.string().trim().max(500).optional(),
|
||||
/** Rendered as a link, so only web addresses. */
|
||||
link: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(500)
|
||||
.url()
|
||||
.refine((u) => /^https?:\/\//i.test(u))
|
||||
.optional(),
|
||||
at: z.string(),
|
||||
updatedAt: z.string(),
|
||||
});
|
||||
@@ -74,9 +89,10 @@ export function taskStatus(input: {
|
||||
now: number;
|
||||
logged: readonly string[];
|
||||
amounts?: AmountLog | undefined;
|
||||
timing?: ResetTiming;
|
||||
}): GoalStatus {
|
||||
const { task, region, now, logged, amounts } = input;
|
||||
const today = dayKey(now, task.clock ?? region, task.game as LaneId);
|
||||
const today = dayKey(now, task.clock ?? region, task.game as LaneId, task.clock === undefined ? input.timing : undefined);
|
||||
const { start, days } = periodOf(task.period, today);
|
||||
const scope = task.period.unit;
|
||||
const left = days.filter((k) => k >= today);
|
||||
@@ -123,7 +139,9 @@ export function asOfLabel(day: string): string {
|
||||
/** One line saying when it rolls and what it wants. */
|
||||
export function taskCaption(task: Pick<Task, "period" | "measure">): string {
|
||||
const when =
|
||||
task.period.unit === "week"
|
||||
task.period.unit === "day"
|
||||
? "every day"
|
||||
: task.period.unit === "week"
|
||||
? `every week from ${WEEKDAYS[task.period.startsOn - 1]}`
|
||||
: task.period.unit === "month"
|
||||
? `every month from the ${ordinal(task.period.startsOn)}`
|
||||
|
||||
+11
-7
@@ -8,6 +8,8 @@ import type { GameId, Precision } from "./schema.ts";
|
||||
* how long is left, how far through a window we are, and how alarmed to be.
|
||||
*/
|
||||
|
||||
export interface ResetTiming { offsetUtc: number; resetHour: number; resetMinute: number }
|
||||
|
||||
export const MINUTE = 60_000;
|
||||
export const HOUR = 60 * MINUTE;
|
||||
export const DAY = 24 * HOUR;
|
||||
@@ -86,16 +88,16 @@ export function resetHourFor(region: Region, game?: LaneId): number {
|
||||
* one, but it is still the reader's streak moving under them. Treat a change
|
||||
* here as a data change, not a constant.
|
||||
*/
|
||||
export function resetShiftMs(region: Region, game?: LaneId): number {
|
||||
return serverOffsetUtc(region, game) * HOUR - resetHourFor(region, game) * HOUR;
|
||||
export function resetShiftMs(region: Region, game?: LaneId, timing?: ResetTiming): number {
|
||||
return timing === undefined ? serverOffsetUtc(region, game) * HOUR - resetHourFor(region, game) * HOUR : timing.offsetUtc * HOUR - timing.resetHour * HOUR - timing.resetMinute * MINUTE;
|
||||
}
|
||||
|
||||
/**
|
||||
* The instant the game-day labelled `day` (`YYYY-MM-DD`) opens on — the inverse
|
||||
* of `dayKey`.
|
||||
*/
|
||||
export function dayStartMs(day: string, region: Region, game?: LaneId): number {
|
||||
return Date.parse(`${day}T00:00:00.000Z`) - resetShiftMs(region, game);
|
||||
export function dayStartMs(day: string, region: Region, game?: LaneId, timing?: ResetTiming): number {
|
||||
return Date.parse(`${day}T00:00:00.000Z`) - resetShiftMs(region, game, timing);
|
||||
}
|
||||
|
||||
export function guessRegion(
|
||||
@@ -191,11 +193,12 @@ function boundaryMs(
|
||||
precision: Precision,
|
||||
event: Pick<Clockable, "game" | "extractionMethod">,
|
||||
region: Region,
|
||||
timing?: ResetTiming,
|
||||
): number {
|
||||
if (precision !== "day" || event.extractionMethod !== "parser") {
|
||||
return Date.parse(iso);
|
||||
}
|
||||
return dayStartMs(iso.slice(0, 10), region, event.game);
|
||||
return dayStartMs(iso.slice(0, 10), region, event.game, timing);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -308,10 +311,11 @@ export function clockFor(
|
||||
event: Clockable,
|
||||
region: Region,
|
||||
now: number,
|
||||
timing?: ResetTiming,
|
||||
): EventClock {
|
||||
const startsMs = boundaryMs(event.startsAt, event.startPrecision, event, region);
|
||||
const startsMs = boundaryMs(event.startsAt, event.startPrecision, event, region, timing);
|
||||
const end = endBoundary(event, region);
|
||||
const endsMs = end === null ? null : boundaryMs(end.iso, end.precision, event, region);
|
||||
const endsMs = end === null ? null : boundaryMs(end.iso, end.precision, event, region, timing);
|
||||
|
||||
const msRemaining = endsMs === null ? null : endsMs - now;
|
||||
const upcoming = now < startsMs;
|
||||
|
||||
Reference in New Issue
Block a user