Category hierarchy, emoji system, and type normalization for Tada v0.1.0
Status: Planning
Created: 2026-01-11
Design Doc: ontology.md
- Overview
- Schema Changes
- Category Defaults Config
- Timer Page Updates
- Add Page Updates
- Journal Page Updates
- Timeline Updates
- Habit Matching Updates
- API Updates
- Testing Checklist
This implementation adds three-level entry classification (type/category/subcategory) with emoji and color support.
- Add
category,subcategory,emojicolumns to entries table - Create
categorySettingstable for future user customization - Create shared category defaults config with emojis and colors
- Update timer to use new ontology (
type: "timed"+ category/subcategory) - Update add/journal pages to populate category/subcategory
- Update timeline to display emoji badges with category colors
- Simplify habit matching to use new top-level fields
app/utils/categoryDefaults.ts— Default categories, subcategories, emojis, colors
app/server/db/schema.ts— Add fields and new tableapp/server/db/migrations/— New migration fileapp/pages/timer.vue— Use new ontologyapp/pages/add.vue— Add category/subcategory selectionapp/pages/journal.vue— Display with emojiapp/pages/index.vue— Emoji badges in timelineapp/server/api/entries/*.ts— Handle new fields
// In entries table definition, add after 'name':
category: text("category"), // "mindfulness", "accomplishment", etc.
subcategory: text("subcategory"), // "sitting", "work", etc.
emoji: text("emoji"), // Per-entry override (nullable)export const categorySettings = sqliteTable("category_settings", {
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
category: text("category").notNull(),
subcategory: text("subcategory"), // null = category-level setting
emoji: text("emoji"),
color: text("color"),
createdAt: text("created_at")
.notNull()
.default(sql`(datetime('now'))`),
updatedAt: text("updated_at")
.notNull()
.default(sql`(datetime('now'))`),
});Rename category to subcategory and add category:
// Change:
category: text('category').notNull(), // 'sitting', 'breathing', etc.
// To:
category: text("category").notNull(), // "mindfulness", "creative", etc.
subcategory: text("subcategory").notNull(), // "sitting", "piano", etc.Add direct matching fields (simpler than JSON matchers):
// Add new fields:
matchType: text("match_type"),
matchCategory: text("match_category"),
matchSubcategory: text("match_subcategory"),
matchName: text("match_name"),
// Keep activityMatchers for backward compatibility, but prefer new fieldscd app
bun run db:generateThis will create a new migration file with ALTER TABLE statements.
Create app/utils/categoryDefaults.ts:
/**
* Category Defaults - Emojis, colors, and subcategories
* See design/ontology.md for full documentation
*/
export interface CategoryDefinition {
emoji: string;
color: string;
label: string;
subcategories: SubcategoryDefinition[];
}
export interface SubcategoryDefinition {
slug: string;
emoji: string;
label: string;
}
export const CATEGORY_DEFAULTS: Record<string, CategoryDefinition> = {
mindfulness: {
emoji: "🧘",
color: "#7C3AED",
label: "Mindfulness",
subcategories: [
{ slug: "sitting", emoji: "🧘", label: "Sitting Meditation" },
{ slug: "breathing", emoji: "🫁", label: "Breathing Exercise" },
{ slug: "walking", emoji: "🚶", label: "Walking Meditation" },
{ slug: "body_scan", emoji: "🫀", label: "Body Scan" },
{ slug: "loving_kindness", emoji: "💗", label: "Loving-Kindness" },
{ slug: "prayer", emoji: "🙏", label: "Prayer" },
{ slug: "visualization", emoji: "🌈", label: "Visualization" },
],
},
movement: {
emoji: "🏃",
color: "#059669",
label: "Movement",
subcategories: [
{ slug: "yoga", emoji: "🧘♀️", label: "Yoga" },
{ slug: "tai_chi", emoji: "🥋", label: "Tai Chi" },
{ slug: "running", emoji: "🏃", label: "Running" },
{ slug: "walking", emoji: "🚶", label: "Walking" },
{ slug: "cycling", emoji: "🚴", label: "Cycling" },
{ slug: "strength", emoji: "💪", label: "Strength Training" },
{ slug: "gym", emoji: "🏋️", label: "Gym" },
{ slug: "swimming", emoji: "🏊", label: "Swimming" },
{ slug: "dance", emoji: "💃", label: "Dance" },
],
},
creative: {
emoji: "🎵",
color: "#D97706",
label: "Creative",
subcategories: [
{ slug: "music", emoji: "🎵", label: "Music Practice" },
{ slug: "piano", emoji: "🎹", label: "Piano" },
{ slug: "guitar", emoji: "🎸", label: "Guitar" },
{ slug: "singing", emoji: "🎤", label: "Singing" },
{ slug: "art", emoji: "🎨", label: "Art" },
{ slug: "writing", emoji: "✍️", label: "Writing" },
{ slug: "coding", emoji: "💻", label: "Coding" },
{ slug: "crafts", emoji: "🧶", label: "Crafts" },
],
},
learning: {
emoji: "📚",
color: "#2563EB",
label: "Learning",
subcategories: [
{ slug: "lesson", emoji: "📚", label: "Lesson" },
{ slug: "reading", emoji: "📖", label: "Reading" },
{ slug: "language", emoji: "🗣️", label: "Language" },
{ slug: "course", emoji: "🎓", label: "Course" },
{ slug: "practice", emoji: "🎯", label: "Practice" },
],
},
journal: {
emoji: "📝",
color: "#6366F1",
label: "Journal",
subcategories: [
{ slug: "dream", emoji: "🌙", label: "Dream" },
{ slug: "gratitude", emoji: "🙏", label: "Gratitude" },
{ slug: "reflection", emoji: "💭", label: "Reflection" },
{ slug: "note", emoji: "📝", label: "Note" },
{ slug: "serendipity", emoji: "✨", label: "Serendipity" },
{ slug: "memory", emoji: "📸", label: "Memory" },
],
},
accomplishment: {
emoji: "⚡",
color: "#F59E0B",
label: "Accomplishment",
subcategories: [
{ slug: "home", emoji: "🏠", label: "Home" },
{ slug: "work", emoji: "💼", label: "Work" },
{ slug: "personal", emoji: "🎯", label: "Personal" },
{ slug: "hobby", emoji: "🎨", label: "Hobby" },
{ slug: "social", emoji: "👫", label: "Social" },
{ slug: "health", emoji: "💚", label: "Health" },
],
},
events: {
emoji: "🎭",
color: "#EC4899",
label: "Events",
subcategories: [
{ slug: "concert", emoji: "🎵", label: "Concert" },
{ slug: "movie", emoji: "🎬", label: "Movie" },
{ slug: "theatre", emoji: "🎭", label: "Theatre" },
{ slug: "exhibition", emoji: "🖼️", label: "Exhibition" },
{ slug: "talk", emoji: "🎤", label: "Talk" },
{ slug: "sports", emoji: "🏟️", label: "Sports Event" },
],
},
};
// Flat lookup for subcategories (any category)
export const SUBCATEGORY_DEFAULTS: Record<
string,
{ emoji: string; label: string; category: string }
> = {};
// Build the flat lookup
for (const [categorySlug, category] of Object.entries(CATEGORY_DEFAULTS)) {
for (const subcat of category.subcategories) {
// If same slug exists in multiple categories, first one wins (or we could make it category-specific)
if (!SUBCATEGORY_DEFAULTS[subcat.slug]) {
SUBCATEGORY_DEFAULTS[subcat.slug] = {
emoji: subcat.emoji,
label: subcat.label,
category: categorySlug,
};
}
}
}
// Default fallbacks
export const DEFAULT_EMOJI = "📌";
export const DEFAULT_COLOR = "#6B7280";
/**
* Get display properties for an entry
*/
export function getEntryDisplayProps(entry: {
emoji?: string | null;
category?: string | null;
subcategory?: string | null;
}): { emoji: string; color: string; label: string } {
const emoji =
entry.emoji ||
(entry.subcategory && SUBCATEGORY_DEFAULTS[entry.subcategory]?.emoji) ||
(entry.category && CATEGORY_DEFAULTS[entry.category]?.emoji) ||
DEFAULT_EMOJI;
const color =
(entry.category && CATEGORY_DEFAULTS[entry.category]?.color) ||
DEFAULT_COLOR;
const label =
(entry.subcategory && SUBCATEGORY_DEFAULTS[entry.subcategory]?.label) ||
(entry.category && CATEGORY_DEFAULTS[entry.category]?.label) ||
"Entry";
return { emoji, color, label };
}
/**
* Get subcategories for a category
*/
export function getSubcategoriesForCategory(
category: string
): SubcategoryDefinition[] {
return CATEGORY_DEFAULTS[category]?.subcategories || [];
}File: app/pages/timer.vue
- Uses
type: "meditation"when saving entries - Stores category in
data.category(e.g., "sitting", "breathing") - Categories array hardcoded with emoji icons
-
Update type normalization: Save as
type: "timed"instead oftype: "meditation" -
Add category field: Set
category: "mindfulness"(or "creative" for music, etc.) -
Move subcategory to top-level: Use
subcategoryfield instead ofdata.category -
Import category defaults: Use
CATEGORY_DEFAULTSandgetSubcategoriesForCategory() -
Update categories array: Generate from defaults instead of hardcoding
// Before:
const categories = [
{ value: "sitting", label: "Sitting", icon: "🧘" },
// ...
];
// After:
import {
CATEGORY_DEFAULTS,
getSubcategoriesForCategory,
} from "~/utils/categoryDefaults";
const selectedCategory = ref("mindfulness");
const subcategories = computed(() =>
getSubcategoriesForCategory(selectedCategory.value).map((s) => ({
value: s.slug,
label: s.label,
icon: s.emoji,
}))
);-
Display large emoji: Show selected subcategory emoji prominently on timer screen
-
Update entry creation:
// Before:
const entry = {
type: "meditation",
name: `${categoryLabel} (${formattedDuration})`,
data: { category: selectedCategory.value, ... },
...
};
// After:
const entry = {
type: "timed",
category: "mindfulness", // or derived from UI
subcategory: selectedSubcategory.value,
name: `${subcategoryLabel} (${formattedDuration})`,
data: { ... }, // No longer needs category here
...
};File: app/pages/add.vue
- Entry types: "tada", "dream", "note", "meditation"
- Uses
typefield directly for these values
- Normalize entry types:
| Old | New Type | New Category | New Subcategory |
|---|---|---|---|
tada |
tada |
accomplishment |
(user picks: home/work/personal) |
dream |
journal |
journal |
dream |
note |
journal |
journal |
note |
meditation |
(redirect to timer) | - | - |
- Add subcategory picker for tadas:
<select v-if="entryType === 'tada'" v-model="subcategory">
<option value="home">🏠 Home</option>
<option value="work">💼 Work</option>
<option value="personal">🎯 Personal</option>
<option value="hobby">🎨 Hobby</option>
<option value="social">👫 Social</option>
</select>- Auto-set category based on type:
const category = computed(() => {
switch (entryType.value) {
case "tada":
return "accomplishment";
case "dream":
case "note":
return "journal";
default:
return null;
}
});
const subcategory = computed(() => {
if (entryType.value === "dream") return "dream";
if (entryType.value === "note") return "note";
return selectedSubcategory.value;
});- Show emoji in entry type buttons:
<button @click="entryType = 'tada'">
⚡ Tada!
</button>
<button @click="entryType = 'dream'">
🌙 Dream
</button>
<button @click="entryType = 'note'">
📝 Note
</button>- Update entry creation:
const entry = {
type: entryType.value === "tada" ? "tada" : "journal",
category: category.value,
subcategory: subcategory.value,
name: title.value || `${subcategoryLabel} entry`,
// ...
};File: app/pages/journal.vue
- Filters entries where type is one of
['dream', 'note', 'journal', 'tada'] - Displays type as badge
- Update filter logic:
// Before:
const journalTypes = ["dream", "note", "journal", "tada"];
const isJournalEntry = journalTypes.includes(entry.type);
// After (filter by type, not by old type strings):
const isJournalEntry = entry.type === "journal" || entry.type === "tada";- Display emoji badge instead of type text:
<!-- Before -->
<span class="badge">{{ entry.type }}</span>
<!-- After -->
<span
class="badge"
:style="{ backgroundColor: getEntryDisplayProps(entry).color }"
>
{{ getEntryDisplayProps(entry).emoji }}
</span>- Group or filter by category/subcategory:
Add filter dropdown to show only dreams, only tadas, etc.
<select v-model="filter">
<option value="">All</option>
<option value="dream">🌙 Dreams</option>
<option value="tada">⚡ Tadas</option>
<option value="gratitude">🙏 Gratitude</option>
<option value="note">📝 Notes</option>
</select>File: app/pages/index.vue
- Displays entries in a list with type badge
- No emoji or color coding
- Import display helpers:
import {
getEntryDisplayProps,
CATEGORY_DEFAULTS,
} from "~/utils/categoryDefaults";- Add emoji badge to each entry:
<template>
<div v-for="entry in entries" :key="entry.id" class="entry-row">
<!-- Emoji badge -->
<span
class="emoji-badge"
:style="{ backgroundColor: getEntryDisplayProps(entry).color + '20' }"
>
{{ getEntryDisplayProps(entry).emoji }}
</span>
<!-- Entry content -->
<div class="entry-content">
<span class="entry-name">{{ entry.name }}</span>
<span class="entry-meta">
{{ entry.category }}
<span v-if="entry.subcategory"> • {{ entry.subcategory }}</span>
</span>
</div>
<!-- Duration/time -->
<span v-if="entry.durationSeconds" class="entry-duration">
{{ formatDuration(entry.durationSeconds) }}
</span>
</div>
</template>- Style emoji badges:
.emoji-badge {
font-size: 1.5rem;
width: 2.5rem;
height: 2.5rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.5rem;
flex-shrink: 0;
}- Optional: Group by category:
Add view toggle to group entries by category instead of chronological.
- Category filter pills:
<div class="category-filters">
<button
v-for="(cat, slug) in CATEGORY_DEFAULTS"
:key="slug"
:class="{ active: activeFilter === slug }"
@click="activeFilter = slug"
>
{{ cat.emoji }} {{ cat.label }}
</button>
</div>File: app/server/db/schema.ts (habits table)
- Uses
activityMatchersJSON array with complex matcher objects - Matches on
field: 'name' | 'type' | 'tag' | 'category'
- Add direct matching fields (simpler, more efficient):
export const habits = sqliteTable("habits", {
// ... existing fields ...
// New: Direct matching (simpler than JSON matchers)
matchType: text("match_type"), // e.g., "timed"
matchCategory: text("match_category"), // e.g., "mindfulness"
matchSubcategory: text("match_subcategory"), // e.g., "sitting"
matchName: text("match_name"), // e.g., "meditation"
// Keep activityMatchers for complex cases, but prefer new fields
});- Update habit matching logic:
function entryMatchesHabit(entry: Entry, habit: Habit): boolean {
// Use new direct fields if available
if (habit.matchType && entry.type !== habit.matchType) return false;
if (habit.matchCategory && entry.category !== habit.matchCategory)
return false;
if (habit.matchSubcategory && entry.subcategory !== habit.matchSubcategory)
return false;
if (
habit.matchName &&
!entry.name.toLowerCase().includes(habit.matchName.toLowerCase())
)
return false;
// Fall back to legacy matchers if present
// ...
return true;
}- Example habit definitions:
// "Daily Meditation" habit
{
name: "Daily Meditation",
matchType: "timed",
matchCategory: "mindfulness",
goalType: "duration",
goalValue: 6, // 6 minutes
goalUnit: "minutes",
frequency: "daily",
}
// "Morning Tai Chi" habit
{
name: "Morning Tai Chi",
matchType: "timed",
matchCategory: "mindfulness",
matchSubcategory: "tai_chi",
goalType: "boolean",
goalValue: 1,
frequency: "daily",
}Files: app/server/api/entries/index.post.ts, app/server/api/entries/[id].patch.ts
- Accept new fields in request body:
const body = await readBody(event);
const { type, name, category, subcategory, emoji, ...rest } = body;- Validate category/subcategory (optional, since open strings allowed):
import { CATEGORY_DEFAULTS } from "~/utils/categoryDefaults";
// Warn if unknown category (but allow it)
if (category && !CATEGORY_DEFAULTS[category]) {
console.warn(`Unknown category: ${category}`);
}- Include in database insert:
const newEntry = await db
.insert(entries)
.values({
id: generateId(),
userId: session.userId,
type,
name,
category,
subcategory,
emoji,
// ...rest
})
.returning();File: app/server/api/entries/index.get.ts
-
Return new fields (automatic if selecting all columns)
-
Add category filter (optional enhancement):
const { category, subcategory } = getQuery(event);
let query = db.select().from(entries).where(eq(entries.userId, session.userId));
if (category) {
query = query.where(eq(entries.category, category));
}
if (subcategory) {
query = query.where(eq(entries.subcategory, subcategory));
}- Run
bun run db:generatesuccessfully - Run
bun run db:migratesuccessfully - New columns appear in entries table
- categorySettings table created
- timerPresets updated with category/subcategory
- Timer shows subcategory picker with emojis
- Large emoji displays during countdown
- Completing timer creates entry with
type: "timed" - Entry has
category: "mindfulness"(or appropriate) - Entry has
subcategorymatching selection - Entry name includes subcategory label
- Tada button shows ⚡ emoji
- Dream button shows 🌙 emoji
- Note button shows 📝 emoji
- Tada creation has subcategory picker (home/work/personal)
- Tada entry has
type: "tada",category: "accomplishment" - Dream entry has
type: "journal",category: "journal",subcategory: "dream" - Note entry has
type: "journal",category: "journal",subcategory: "note"
- Each entry shows emoji badge
- Badge background uses category color
- Category and subcategory shown in entry metadata
- Entries display correctly for all types
- Filter works for journal and tada entries
- Emoji badges display correctly
- Category filter (if added) works
- POST /api/entries accepts category, subcategory, emoji
- GET /api/entries returns new fields
- PATCH /api/entries/[id] can update new fields
- Entry with unknown category displays with fallback (📌 gray)
- Entry without category/subcategory displays gracefully
- Old entries (if any exist) display without errors
- Schema changes — foundation for everything else
- Category defaults config — shared constants
- Timer page — highest-traffic entry creation
- Add page — other entry types
- Timeline — primary display surface
- Journal page — secondary display
- API updates — filtering enhancements
- Habit matching — can defer to v0.1.1 if needed
Implementation plan for Tada v0.1.0 ontology feature. See ontology.md for design rationale.