Skip to content

Commit b871f89

Browse files
InfantLabclaude
andcommitted
feat(ourmoji): accept timestamp, category, subcategory on admin daily ingest
Lets the home-server agent post entries that reflect when they were generated and match the "Moments → Magic" bucket manually-created Ourmojis use. All three fields are optional; `timestamp` defaults to server-side NOW and must share its calendar date with `date` to keep per-day idempotency sound. `category` / `subcategory` default to `"moments"` / `"magic"` for new entries (in-app submissions pick this up too). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4558024 commit b871f89

5 files changed

Lines changed: 82 additions & 14 deletions

File tree

app/server/api/ourmoji/schemas.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,30 @@ export type DailyOurmojiPayload = z.infer<typeof dailyOurmojiPayloadSchema>;
3939
* Admin variant — same fields as `dailyOurmojiPayloadSchema` plus a
4040
* target `userId` so a privileged caller can post on behalf of any
4141
* user. Used by `POST /api/v1/admin/ourmoji/daily`.
42+
*
43+
* Additional optional fields let a trusted agent post entries that match
44+
* manually-created ones more closely:
45+
* - `timestamp` — ISO-8601 datetime when the reading was generated.
46+
* Defaults to server-side NOW when omitted. If its calendar date does
47+
* not match `date`, the request is rejected (keeps idempotency sound).
48+
* - `category` / `subcategory` — optional category hierarchy labels
49+
* (e.g. "Moments" / "Magic") written to the top-level columns on
50+
* `entries`, for parity with in-app submissions.
4251
*/
43-
export const adminDailyOurmojiPayloadSchema =
44-
dailyOurmojiPayloadSchema.extend({
52+
export const adminDailyOurmojiPayloadSchema = dailyOurmojiPayloadSchema
53+
.extend({
4554
userId: z.string().min(1),
46-
});
55+
timestamp: z.string().datetime({ offset: true }).nullable().optional(),
56+
category: z.string().min(1).nullable().optional(),
57+
subcategory: z.string().min(1).nullable().optional(),
58+
})
59+
.refine(
60+
(p) => !p.timestamp || p.timestamp.slice(0, 10) === p.date,
61+
{
62+
message: "timestamp date must match the `date` field (YYYY-MM-DD)",
63+
path: ["timestamp"],
64+
},
65+
);
4766

4867
export type AdminDailyOurmojiPayload = z.infer<
4968
typeof adminDailyOurmojiPayloadSchema

app/server/api/v1/admin/ourmoji/daily.post.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,10 @@ defineRouteMeta({
2121
description:
2222
"Posts a single day's Ourmoji payload for a target user. Intended " +
2323
"for trusted server-to-server agents authenticated with an admin " +
24-
"API key. Idempotent per (userId, date).",
24+
"API key. Idempotent per (userId, date). " +
25+
"Optional `timestamp` (ISO-8601) defaults to server-side NOW; its " +
26+
"calendar date must match `date`. Optional `category` / " +
27+
"`subcategory` default to 'moments' / 'magic' for new entries.",
2528
security: [{ bearerAuth: ["admin:users:write"] }],
2629
requestBody: {
2730
required: true,
@@ -52,6 +55,13 @@ defineRouteMeta({
5255
wheelOfYear: { type: "string", nullable: true },
5356
wheelCategory: { type: "string", nullable: true },
5457
timezone: { type: "string" },
58+
timestamp: {
59+
type: "string",
60+
format: "date-time",
61+
nullable: true,
62+
},
63+
category: { type: "string", nullable: true },
64+
subcategory: { type: "string", nullable: true },
5565
},
5666
},
5767
},
@@ -133,6 +143,9 @@ export default defineEventHandler(async (event) => {
133143
wheelCategory: payload.wheelCategory ?? null,
134144
timezone: payload.timezone,
135145
source: "api",
146+
timestamp: payload.timestamp ?? null,
147+
category: payload.category ?? undefined,
148+
subcategory: payload.subcategory ?? undefined,
136149
});
137150

138151
const auth = event.context.auth!;

app/server/services/ourmoji/daily.ts

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@ import type {
2222
import { OURMOJI_ENTRY_TYPE } from "~/utils/ourmoji/constants";
2323
import { ourmojiChildLogger } from "./logger";
2424

25+
/**
26+
* Default category/subcategory slugs for Ourmoji entries, matching the
27+
* in-app "Moments → Magic" bucket in `categoryDefaults.ts`.
28+
*/
29+
const DEFAULT_OURMOJI_CATEGORY = "moments";
30+
const DEFAULT_OURMOJI_SUBCATEGORY = "magic";
31+
2532
const logger = ourmojiChildLogger("service:daily");
2633

2734
export interface DailyIngestInput {
@@ -36,6 +43,11 @@ export interface DailyIngestInput {
3643
timezone: string;
3744
/** Source label — `manual` for in-app submissions, `api` for OpenClaw. */
3845
source?: "manual" | "api";
46+
/** ISO-8601 datetime when the reading was generated. Defaults to NOW. */
47+
timestamp?: string | null;
48+
/** Optional category hierarchy labels, matching `entries.category`. */
49+
category?: string | null;
50+
subcategory?: string | null;
3951
}
4052

4153
/**
@@ -72,6 +84,11 @@ export async function upsertDailyOurmoji(
7284
timezone: input.timezone,
7385
source: input.source ?? "api",
7486
updatedAt: sql`(datetime('now'))`,
87+
...(input.timestamp ? { timestamp: input.timestamp } : {}),
88+
...(input.category !== undefined ? { category: input.category } : {}),
89+
...(input.subcategory !== undefined
90+
? { subcategory: input.subcategory }
91+
: {}),
7592
})
7693
.where(eq(entries.id, existing.id))
7794
.returning();
@@ -90,10 +107,12 @@ export async function upsertDailyOurmoji(
90107
name: "Ourmoji",
91108
emoji: input.emoji,
92109
notes: input.reflection,
93-
timestamp: `${input.date}T00:00:00Z`,
110+
timestamp: input.timestamp ?? new Date().toISOString(),
94111
timezone: input.timezone,
95112
data: data as unknown as Record<string, unknown>,
96113
source: input.source ?? "api",
114+
category: input.category ?? DEFAULT_OURMOJI_CATEGORY,
115+
subcategory: input.subcategory ?? DEFAULT_OURMOJI_SUBCATEGORY,
97116
};
98117

99118
const [created] = await db.insert(entries).values(newRow).returning();
@@ -104,15 +123,20 @@ export async function upsertDailyOurmoji(
104123
* Look up the Ourmoji entry for `userId` on `date` (YYYY-MM-DD), if any.
105124
*
106125
* Implementation note: the per-day uniqueness key lives in `data.date`,
107-
* not in a top-level column. We narrow with a timestamp range first
126+
* not in a top-level column. We narrow with a ±1-day timestamp range
108127
* (cheap, indexed) and then filter rows whose JSON `date` matches.
128+
* The widened window accommodates timezone offsets: a local-date entry
129+
* stored with a UTC timestamp may fall into the prior or next UTC day.
109130
*/
110131
export async function findDailyEntry(
111132
userId: string,
112133
date: string,
113134
): Promise<Entry | undefined> {
114-
const dayStart = `${date}T00:00:00Z`;
115-
const dayEnd = `${date}T23:59:59Z`;
135+
const windowStart = new Date(`${date}T00:00:00Z`);
136+
windowStart.setUTCDate(windowStart.getUTCDate() - 1);
137+
const windowEnd = new Date(`${date}T23:59:59Z`);
138+
windowEnd.setUTCDate(windowEnd.getUTCDate() + 1);
139+
116140
const candidates = await db
117141
.select()
118142
.from(entries)
@@ -121,8 +145,8 @@ export async function findDailyEntry(
121145
eq(entries.userId, userId),
122146
eq(entries.type, OURMOJI_ENTRY_TYPE),
123147
isNull(entries.deletedAt),
124-
gte(entries.timestamp, dayStart),
125-
lte(entries.timestamp, dayEnd),
148+
gte(entries.timestamp, windowStart.toISOString()),
149+
lte(entries.timestamp, windowEnd.toISOString()),
126150
),
127151
);
128152
return candidates.find((row) => {
@@ -175,5 +199,7 @@ function rowToDTO(row: Entry): OurmojiDailyCardDTO {
175199
wheelCategory: data.wheelCategory ?? null,
176200
timestamp: row.timestamp,
177201
timezone: row.timezone,
202+
category: row.category ?? null,
203+
subcategory: row.subcategory ?? null,
178204
};
179205
}

app/types/ourmoji.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ export interface OurmojiDailyCardDTO {
5656
wheelCategory: string | null;
5757
timestamp: string;
5858
timezone: string;
59+
category: string | null;
60+
subcategory: string | null;
5961
}
6062

6163
/** Stats DTO — active runs return only redacted progress. */

docs/modules/ourmoji-agent-setup.md

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -108,13 +108,16 @@ curl -X POST https://tada.living/api/v1/admin/ourmoji/daily \
108108
-d '{
109109
"userId": "<CASPAR_USER_ID>",
110110
"date": "2026-04-21",
111+
"timestamp": "2026-04-21T08:00:00+01:00",
111112
"emoji": "🌙",
112113
"reflection": "The waning gibbous whispers of release.",
113114
"moonPhase": "Waning Gibbous",
114115
"moonIllumination": 75,
115116
"wheelOfYear": "Beltane",
116117
"wheelCategory": "fire",
117-
"timezone": "Europe/London"
118+
"timezone": "Europe/London",
119+
"category": "moments",
120+
"subcategory": "magic"
118121
}'
119122
```
120123

@@ -123,14 +126,17 @@ curl -X POST https://tada.living/api/v1/admin/ourmoji/daily \
123126
| Field | Type | Required | Notes |
124127
|-------|------|----------|-------|
125128
| `userId` | string | yes | Target user (Caspar or Marian) |
126-
| `date` | string `YYYY-MM-DD` | yes | Local date the entry belongs to |
129+
| `date` | string `YYYY-MM-DD` | yes | Local date the entry belongs to; idempotency key |
127130
| `emoji` | string (1–16 chars) | yes | Drawn from the 23-emoji Sacred Set |
128131
| `reflection` | string (≤ 5000 chars) | yes | Poetic reflection text |
129132
| `moonPhase` | string | yes | e.g. "Waning Gibbous" |
130133
| `moonIllumination` | number 0–100 | no | Percent illuminated |
131134
| `wheelOfYear` | string | no | e.g. "Beltane", "Samhain" |
132135
| `wheelCategory` | string | no | e.g. "fire", "water" |
133136
| `timezone` | string | yes | IANA zone, e.g. "Europe/London" |
137+
| `timestamp` | string ISO-8601 | no | When the reading was generated. Defaults to server-side NOW. Its calendar date must match `date` |
138+
| `category` | string | no | Category slug. Defaults to `"moments"` for new Ourmoji entries |
139+
| `subcategory` | string | no | Subcategory slug. Defaults to `"magic"` for new Ourmoji entries |
134140

135141
### Idempotency
136142

@@ -152,8 +158,10 @@ network failure.
152158
"moonIllumination": 75,
153159
"wheelOfYear": "Beltane",
154160
"wheelCategory": "fire",
155-
"timestamp": "2026-04-21T00:00:00Z",
156-
"timezone": "Europe/London"
161+
"timestamp": "2026-04-21T08:00:00+01:00",
162+
"timezone": "Europe/London",
163+
"category": "moments",
164+
"subcategory": "magic"
157165
}
158166
}
159167
}

0 commit comments

Comments
 (0)