mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-18 07:09:40 +02:00
Pre-launch theme system audit found multiple parallel layers in themes.css
(--theme-X full hsl strings, --X partial shadcn aliases, --color-X populated
by runtime store with raw channels) plus dead-code companion files. The
inconsistency caused light-mode regressions when scoped-CSS consumers
wrote `var(--color-X)` standalone — the variable holds raw HSL channels
which is invalid as a color value, browser fell back to inherited (white).
Rewrite to one consistent layer:
- Source of truth: --color-X defined as raw HSL channels (e.g.
`0 0% 17%`) in :root, .dark, and all variant [data-theme="..."]
blocks. Matches the format the runtime store
(@mana/shared-theme/src/utils.ts) writes, eliminating the
static-fallback-vs-runtime mismatch and the corresponding flash
of unstyled content on hydration.
- @theme inline uses self-reference + Tailwind v4 <alpha-value>
placeholder so utility classes generate correctly AND opacity
modifiers work: `text-foreground/50` → `hsl(var(--color-foreground) / 0.5)`.
- @layer components (.btn-primary, .card, .badge, etc.) wraps
var(--color-X) refs with hsl() — they were broken in light mode
too for the same reason.
Convention going forward (also documented in the file header):
1. Markup: use Tailwind utility classes (text-foreground, bg-card, …)
2. Scoped CSS: hsl(var(--color-X)) — always wrap with hsl()
3. NEVER raw var(--color-X) in CSS — that's the bug pattern
Net file: 692 → 580 LOC. Single source layer, no indirection.
Also delete dead companion files (zero imports anywhere):
- tailwind-v4.css (had broken self-reference, never imported)
- theme-variables.css (legacy hex-based palette)
- components.css (legacy component utilities)
- index.js / preset.js / colors.js (Tailwind v3 preset format,
irrelevant under Tailwind v4)
package.json exports map shrinks accordingly to just `./themes.css`.
Consumers using `hsl(var(--color-X))` (~379 files across mana-web,
manavoxel-web, arcade-web) keep working unchanged — the public API
name `--color-X` is preserved. Only the broken pattern `var(--color-X)`
(~61 files) needs a follow-up sweep, handled in a separate commit.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
304 lines
9.6 KiB
TypeScript
304 lines
9.6 KiB
TypeScript
/**
|
|
* Todo module — RRULE compute + reminders + admin
|
|
* Ported from apps/todo/apps/server
|
|
*
|
|
* All CRUD is handled client-side via local-first + sync.
|
|
* This module provides compute-only endpoints.
|
|
*
|
|
* NOTE: The standalone server also runs a background reminder worker
|
|
* (startReminderWorker) that polls for due reminders and dispatches
|
|
* them via mana-notify. That worker needs to be started separately
|
|
* or integrated into the unified API's startup lifecycle.
|
|
* See: apps/todo/apps/server/src/lib/reminder-worker.ts
|
|
*/
|
|
|
|
import { Hono } from 'hono';
|
|
import { rrulestr } from 'rrule';
|
|
import { z } from 'zod';
|
|
import { eq, and, asc, sql } from 'drizzle-orm';
|
|
import { drizzle } from 'drizzle-orm/postgres-js';
|
|
import postgres from 'postgres';
|
|
import { serviceAuthMiddleware, type AuthVariables } from '@mana/shared-hono';
|
|
import {
|
|
pgSchema,
|
|
uuid,
|
|
text,
|
|
timestamp,
|
|
varchar,
|
|
integer,
|
|
boolean,
|
|
jsonb,
|
|
index,
|
|
} from 'drizzle-orm/pg-core';
|
|
|
|
// ─── DB Schema (minimal, server-only) ──────────────────────
|
|
|
|
const DATABASE_URL =
|
|
process.env.DATABASE_URL ?? 'postgresql://mana:devpassword@localhost:5432/mana_platform';
|
|
|
|
const todoSchema = pgSchema('todo');
|
|
|
|
const tasks = todoSchema.table('tasks', {
|
|
id: uuid('id').primaryKey().defaultRandom(),
|
|
userId: text('user_id').notNull(),
|
|
projectId: uuid('project_id'),
|
|
title: varchar('title', { length: 500 }).notNull(),
|
|
description: text('description'),
|
|
dueDate: timestamp('due_date', { withTimezone: true }),
|
|
dueTime: varchar('due_time', { length: 5 }),
|
|
startDate: timestamp('start_date', { withTimezone: true }),
|
|
priority: varchar('priority', { length: 20 }).default('medium'),
|
|
status: varchar('status', { length: 20 }).default('pending'),
|
|
isCompleted: boolean('is_completed').default(false),
|
|
completedAt: timestamp('completed_at', { withTimezone: true }),
|
|
order: integer('order').default(0),
|
|
recurrenceRule: varchar('recurrence_rule', { length: 500 }),
|
|
recurrenceEndDate: timestamp('recurrence_end_date', { withTimezone: true }),
|
|
lastOccurrence: timestamp('last_occurrence', { withTimezone: true }),
|
|
parentTaskId: uuid('parent_task_id'),
|
|
subtasks: jsonb('subtasks'),
|
|
metadata: jsonb('metadata'),
|
|
columnId: uuid('column_id'),
|
|
columnOrder: integer('column_order'),
|
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
|
});
|
|
|
|
const projects = todoSchema.table('projects', {
|
|
id: uuid('id').primaryKey().defaultRandom(),
|
|
userId: text('user_id').notNull(),
|
|
});
|
|
|
|
const reminders = todoSchema.table(
|
|
'reminders',
|
|
{
|
|
id: uuid('id').primaryKey().defaultRandom(),
|
|
taskId: uuid('task_id').notNull(),
|
|
userId: text('user_id').notNull(),
|
|
minutesBefore: integer('minutes_before').notNull(),
|
|
reminderTime: timestamp('reminder_time', { withTimezone: true }).notNull(),
|
|
type: varchar('type', { length: 20 }).default('push'),
|
|
status: varchar('status', { length: 20 }).default('pending'),
|
|
sentAt: timestamp('sent_at', { withTimezone: true }),
|
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
|
},
|
|
(table) => ({
|
|
taskIdx: index('reminders_task_idx_api').on(table.taskId),
|
|
userIdx: index('reminders_user_idx_api').on(table.userId),
|
|
})
|
|
);
|
|
|
|
const connection = postgres(DATABASE_URL, { max: 5, idle_timeout: 20 });
|
|
const db = drizzle(connection, { schema: { tasks, projects, reminders } });
|
|
|
|
// ─── Routes ────────────────────────────────────────────────
|
|
|
|
const routes = new Hono<{ Variables: AuthVariables }>();
|
|
|
|
// ─── RRULE Compute ─────────────────────────────────────────
|
|
|
|
const NextOccurrenceSchema = z.object({
|
|
rrule: z.string().min(1, 'Missing rrule parameter').max(500, 'RRULE too long (max 500 chars)'),
|
|
recurrenceEndDate: z.string().datetime({ offset: true }).optional(),
|
|
after: z.string().datetime({ offset: true }).optional(),
|
|
});
|
|
|
|
const ValidateSchema = z.object({
|
|
rrule: z.string().min(1).max(500),
|
|
});
|
|
|
|
routes.post('/compute/next-occurrence', async (c) => {
|
|
const parsed = NextOccurrenceSchema.safeParse(await c.req.json());
|
|
if (!parsed.success) {
|
|
return c.json({ error: parsed.error.issues[0]?.message ?? 'Invalid input' }, 400);
|
|
}
|
|
|
|
const { rrule: rruleString, recurrenceEndDate, after } = parsed.data;
|
|
|
|
try {
|
|
const rule = rrulestr(rruleString);
|
|
const afterDate = after ? new Date(after) : new Date();
|
|
|
|
// Validate: not too many occurrences
|
|
const maxOccurrences = 5000;
|
|
const tenYearsFromNow = new Date();
|
|
tenYearsFromNow.setFullYear(tenYearsFromNow.getFullYear() + 10);
|
|
|
|
const occurrences = rule.between(new Date(), tenYearsFromNow, true, (_, count) => {
|
|
return count < maxOccurrences;
|
|
});
|
|
|
|
if (occurrences.length >= maxOccurrences) {
|
|
return c.json({ error: 'RRULE generates too many occurrences (max 5000)' }, 400);
|
|
}
|
|
|
|
// Get next occurrence
|
|
const nextDate = rule.after(afterDate, false);
|
|
|
|
// Check recurrence end date
|
|
if (recurrenceEndDate) {
|
|
const endDate = new Date(recurrenceEndDate);
|
|
if (!nextDate || nextDate > endDate) {
|
|
return c.json({ nextDate: null, message: 'No more occurrences (past end date)' });
|
|
}
|
|
}
|
|
|
|
return c.json({
|
|
nextDate: nextDate?.toISOString() ?? null,
|
|
valid: true,
|
|
totalOccurrences: occurrences.length,
|
|
});
|
|
} catch (err) {
|
|
return c.json(
|
|
{ error: 'Invalid RRULE: ' + (err instanceof Error ? err.message : 'unknown') },
|
|
400
|
|
);
|
|
}
|
|
});
|
|
|
|
routes.post('/compute/validate', async (c) => {
|
|
const parsed = ValidateSchema.safeParse(await c.req.json());
|
|
if (!parsed.success) {
|
|
return c.json({ valid: false, error: parsed.error.issues[0]?.message ?? 'Invalid input' });
|
|
}
|
|
|
|
const { rrule: rruleString } = parsed.data;
|
|
|
|
try {
|
|
const rule = rrulestr(rruleString);
|
|
const tenYearsFromNow = new Date();
|
|
tenYearsFromNow.setFullYear(tenYearsFromNow.getFullYear() + 10);
|
|
|
|
const count = rule.between(new Date(), tenYearsFromNow, true, (_, c) => c < 5000).length;
|
|
|
|
return c.json({
|
|
valid: count < 5000,
|
|
occurrences: count,
|
|
error: count >= 5000 ? 'Too many occurrences' : undefined,
|
|
});
|
|
} catch (err) {
|
|
return c.json({ valid: false, error: err instanceof Error ? err.message : 'Invalid RRULE' });
|
|
}
|
|
});
|
|
|
|
// ─── Reminders ─────────────────────────────────────────────
|
|
|
|
routes.get('/tasks/:taskId/reminders', async (c) => {
|
|
const userId = c.get('userId');
|
|
const taskId = c.req.param('taskId');
|
|
|
|
// Verify task belongs to user
|
|
const task = await db.query.tasks.findFirst({
|
|
where: and(eq(tasks.id, taskId), eq(tasks.userId, userId)),
|
|
});
|
|
if (!task) {
|
|
return c.json({ error: 'Task not found' }, 404);
|
|
}
|
|
|
|
const result = await db.query.reminders.findMany({
|
|
where: and(eq(reminders.taskId, taskId), eq(reminders.userId, userId)),
|
|
orderBy: [asc(reminders.minutesBefore)],
|
|
});
|
|
|
|
return c.json({ reminders: result });
|
|
});
|
|
|
|
routes.post('/tasks/:taskId/reminders', async (c) => {
|
|
const userId = c.get('userId');
|
|
const taskId = c.req.param('taskId');
|
|
const body = await c.req.json<{
|
|
minutesBefore: number;
|
|
type?: 'push' | 'email' | 'both';
|
|
}>();
|
|
|
|
// Verify task
|
|
const task = await db.query.tasks.findFirst({
|
|
where: and(eq(tasks.id, taskId), eq(tasks.userId, userId)),
|
|
});
|
|
if (!task) {
|
|
return c.json({ error: 'Task not found' }, 404);
|
|
}
|
|
if (!task.dueDate) {
|
|
return c.json({ error: 'Cannot create reminder for task without due date' }, 400);
|
|
}
|
|
|
|
const dueDate = new Date(task.dueDate);
|
|
const reminderTime = new Date(dueDate.getTime() - body.minutesBefore * 60 * 1000);
|
|
|
|
const [created] = await db
|
|
.insert(reminders)
|
|
.values({
|
|
taskId,
|
|
userId,
|
|
minutesBefore: body.minutesBefore,
|
|
reminderTime,
|
|
type: body.type ?? 'push',
|
|
})
|
|
.returning();
|
|
|
|
return c.json({ reminder: created }, 201);
|
|
});
|
|
|
|
routes.delete('/reminders/:id', async (c) => {
|
|
const userId = c.get('userId');
|
|
const id = c.req.param('id');
|
|
|
|
const existing = await db.query.reminders.findFirst({
|
|
where: and(eq(reminders.id, id), eq(reminders.userId, userId)),
|
|
});
|
|
if (!existing) {
|
|
return c.json({ error: 'Reminder not found' }, 404);
|
|
}
|
|
|
|
await db.delete(reminders).where(and(eq(reminders.id, id), eq(reminders.userId, userId)));
|
|
return c.json({ success: true });
|
|
});
|
|
|
|
// ─── Admin (GDPR) ──────────────────────────────────────────
|
|
|
|
const adminSub = new Hono();
|
|
adminSub.use('/*', serviceAuthMiddleware());
|
|
|
|
adminSub.get('/user-data/:userId', async (c) => {
|
|
const userId = c.req.param('userId');
|
|
|
|
const [taskCount] = await db
|
|
.select({ count: sql<number>`count(*)` })
|
|
.from(tasks)
|
|
.where(eq(tasks.userId, userId));
|
|
const [projectCount] = await db
|
|
.select({ count: sql<number>`count(*)` })
|
|
.from(projects)
|
|
.where(eq(projects.userId, userId));
|
|
const [reminderCount] = await db
|
|
.select({ count: sql<number>`count(*)` })
|
|
.from(reminders)
|
|
.where(eq(reminders.userId, userId));
|
|
|
|
return c.json({
|
|
userId,
|
|
counts: {
|
|
tasks: Number(taskCount?.count ?? 0),
|
|
projects: Number(projectCount?.count ?? 0),
|
|
reminders: Number(reminderCount?.count ?? 0),
|
|
},
|
|
});
|
|
});
|
|
|
|
adminSub.delete('/user-data/:userId', async (c) => {
|
|
const userId = c.req.param('userId');
|
|
|
|
await db.delete(reminders).where(eq(reminders.userId, userId));
|
|
await db.delete(tasks).where(eq(tasks.userId, userId));
|
|
await db.delete(projects).where(eq(projects.userId, userId));
|
|
|
|
return c.json({
|
|
userId,
|
|
deleted: true,
|
|
message: 'All user data deleted',
|
|
});
|
|
});
|
|
|
|
routes.route('/admin', adminSub);
|
|
|
|
export { routes as todoRoutes };
|