Add new styles for home and landing pages
- Created home.css with comprehensive styles for the home page layout, including typography, buttons, and responsive design adjustments. - Created landing.css to style the landing page, focusing on typography, layout, and responsive behavior for various screen sizes.
This commit is contained in:
@@ -16,7 +16,7 @@ const schedule = scheduleSchema.default({ type: 'daily' });
|
||||
export const taskInput = z.object({ name, schedule }).strict();
|
||||
// Creation defaults must not become writes when PATCH omits a property.
|
||||
export const taskPatch = z.object({ name: name.optional(), schedule: scheduleSchema.optional() }).strict().refine(v => Object.keys(v).length > 0);
|
||||
const common = { name, schedule };
|
||||
const common = { name, schedule, color: z.string().regex(/^#[0-9a-fA-F]{6}$/, 'Use a six-digit hex color').optional() };
|
||||
export const habitInput = z.discriminatedUnion('method', [
|
||||
z.object({ ...common, method: z.literal('count'), carryPartialProgress: z.boolean().default(false), target: z.number().int().min(1).max(10000), unit: z.string().trim().min(1).max(80).default('steps') }).strict(),
|
||||
z.object({ ...common, method: z.literal('manual') }).strict(),
|
||||
@@ -27,7 +27,7 @@ export type HabitConfig = { name: string; schedule: Schedule; archived: boolean
|
||||
{ method: 'manual' } |
|
||||
{ method: 'tasks'; tasks: { id: string; name: string; schedule: Schedule }[] }
|
||||
);
|
||||
export const habitPatch = z.object({ carryPartialProgress: z.boolean().optional(), name: name.optional(), schedule: scheduleSchema.optional(), method: z.enum(['count', 'manual', 'tasks']).optional(), target: z.number().int().min(1).max(10000).optional(), unit: z.string().trim().min(1).max(80).optional(), archived: z.boolean().optional() }).strict().refine(v => Object.keys(v).length > 0);
|
||||
export const habitPatch = z.object({ color: common.color, carryPartialProgress: z.boolean().optional(), name: name.optional(), schedule: scheduleSchema.optional(), method: z.enum(['count', 'manual', 'tasks']).optional(), target: z.number().int().min(1).max(10000).optional(), unit: z.string().trim().min(1).max(80).optional(), archived: z.boolean().optional() }).strict().refine(v => Object.keys(v).length > 0);
|
||||
export const progressInput = z.union([z.object({ count: z.number().int().min(0).max(1_000_000_000) }).strict(), z.object({ done: z.boolean() }).strict()]);
|
||||
export const doneInput = z.object({ done: z.boolean() }).strict();
|
||||
const color = z.string().regex(/^#[0-9a-fA-F]{6}$/, 'Use a six-digit hex color');
|
||||
|
||||
@@ -8,6 +8,36 @@ test('failed revision writes roll back the habit atomically and do not expose da
|
||||
expect(response.status).toBe(500); expect(await response.json()).toEqual({ error: 'Internal server error' });
|
||||
expect((await f.json('/habits')).habits).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('creation saves color atomically and never puts presentation into requirements', async () => {
|
||||
for (const method of ['manual', 'count', 'tasks']) {
|
||||
const h = await f.json('/habits', 'POST', { name: method, method, color: '#79618d', ...(method === 'count' ? { target: 8 } : {}) }, 201);
|
||||
expect((await f.json(`/habits/${h.id}/calendar-settings`)).mainColor).toBe('#79618d');
|
||||
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).requirements.color).toBeUndefined();
|
||||
}
|
||||
expect((await f.request('/habits', 'POST', { name: 'Invalid', method: 'manual', color: 'red' })).status).toBe(422);
|
||||
expect((await f.json('/habits')).habits).toHaveLength(3);
|
||||
f.sqlite.exec("CREATE TRIGGER reject_color BEFORE INSERT ON habit_calendar_settings BEGIN SELECT RAISE(ABORT, 'private color detail'); END");
|
||||
const response = await f.request('/habits', 'POST', { name: 'Rollback color', method: 'manual', color: '#426582' });
|
||||
expect(response.status).toBe(500);
|
||||
expect(await response.json()).toEqual({ error: 'Internal server error' });
|
||||
expect((await f.json('/habits')).habits).toHaveLength(3);
|
||||
});
|
||||
test('edits save color with requirements atomically, preserving history and other settings', async () => {
|
||||
const h = await f.json('/habits', 'POST', { name: 'Water', method: 'count', target: 8, color: '#426582' }, 201);
|
||||
await f.json(`/habits/${h.id}/calendar-settings`, 'PUT', { mainColor: '#426582', emptyColor: '#fafafa', shadeCount: 4 });
|
||||
f.setTime('2026-09-05T12:00Z');
|
||||
await f.json(`/habits/${h.id}`, 'PATCH', { target: 10, color: '#79618d' });
|
||||
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).target).toBe(8);
|
||||
expect((await f.json(`/habits/${h.id}/calendar-settings`))).toMatchObject({ mainColor: '#79618d', emptyColor: '#fafafa', shadeCount: 4 });
|
||||
expect((await f.json(`/habits/${h.id}`)).color).toBeUndefined();
|
||||
expect((await f.request(`/habits/${h.id}`, 'PATCH', { color: 'red' })).status).toBe(422);
|
||||
f.sqlite.exec("CREATE TRIGGER reject_color_update BEFORE UPDATE ON habit_calendar_settings BEGIN SELECT RAISE(ABORT, 'private color detail'); END");
|
||||
expect((await f.request(`/habits/${h.id}`, 'PATCH', { name: 'Should roll back', target: 12, color: '#58765b' })).status).toBe(500);
|
||||
expect((await f.json(`/habits/${h.id}`))).toMatchObject({ name: 'Water', target: 10 });
|
||||
expect((await f.json(`/habits/${h.id}/calendar-settings`)).mainColor).toBe('#79618d');
|
||||
});
|
||||
|
||||
test('same-day method switches reset inherited progress even when returning to count', async () => {
|
||||
const h = await f.json('/habits', 'POST', { name: 'Carry', method: 'count', target: 8, carryPartialProgress: true }, 201);
|
||||
await f.json(`/habits/${h.id}/days/2026-09-04/progress`, 'PUT', { count: 7 }); f.setTime('2026-09-05T12:00Z');
|
||||
|
||||
@@ -55,12 +55,14 @@ export class HabitService {
|
||||
}
|
||||
create(input: ReturnType<typeof habitInput.parse>) {
|
||||
const id = crypto.randomUUID();
|
||||
const config: HabitConfig = input.method === 'tasks'
|
||||
? { ...input, archived: false, tasks: input.tasks.map(t => ({ ...t, id: crypto.randomUUID() })) }
|
||||
: { ...input, archived: false };
|
||||
const { color, ...requirements } = input;
|
||||
const config: HabitConfig = requirements.method === 'tasks'
|
||||
? { ...requirements, archived: false, tasks: requirements.tasks.map(t => ({ ...t, id: crypto.randomUUID() })) }
|
||||
: { ...requirements, archived: false };
|
||||
this.db.transaction(() => {
|
||||
this.db.insert(habits).values({ id, userId: this.user.id, createdDate: this.today, createdAt: this.now }).run();
|
||||
this.db.insert(habitRevisions).values({ habitId: id, effectiveDate: this.today, config, createdAt: this.now }).run();
|
||||
if (color) this.saveSettings(id, calendarSettingsSchema.parse({ mainColor: color }));
|
||||
this.materialize(this.owned(id));
|
||||
});
|
||||
return this.current(id);
|
||||
@@ -80,7 +82,13 @@ export class HabitService {
|
||||
const config: HabitConfig = parsed.data.method === 'tasks'
|
||||
? { ...parsed.data, tasks: old.method === 'tasks' ? old.tasks : [], archived: (patch.archived ?? old.archived) as boolean }
|
||||
: { ...parsed.data, archived: (patch.archived ?? old.archived) as boolean };
|
||||
return this.revise(id, config);
|
||||
return this.db.transaction(() => {
|
||||
const updated = this.revise(id, config);
|
||||
if (patch.color !== undefined) {
|
||||
this.saveSettings(id, calendarSettingsSchema.parse({ ...this.settings(id), mainColor: patch.color }));
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
revise(id: string, config: HabitConfig) {
|
||||
this.invalidate(id);
|
||||
|
||||
Reference in New Issue
Block a user