โœจ Skyread

Core Source Code & AI Horoscope Generation

React + TypeScript Gemini AI Framer Motion Vite

๐Ÿ” About This Code Showcase

This curated code walkthrough demonstrates how Skyread generates daily horoscope readings using Google Gemini AI with structured JSON schema output, manages bilingual support, and caches readings in localStorage for a fast return-visit experience.

This showcase focuses on the two most important parts of the codebase: the Gemini AI service layer (geminiService.ts) and the main app logic (App.tsx).

๐Ÿ—‚๏ธ Project File Structure

๐Ÿ“ projects/skyread/
skyread/ โ”œโ”€โ”€ src/ โ”‚ โ”œโ”€โ”€ App.tsx # Main app โ€” screens, state, language toggle โ”‚ โ”œโ”€โ”€ constants.ts # 12 zodiac signs with symbols and date ranges โ”‚ โ”œโ”€โ”€ main.tsx # React entry point โ”‚ โ”œโ”€โ”€ index.css # Tailwind + celestial background animations โ”‚ โ””โ”€โ”€ services/ โ”‚ โ””โ”€โ”€ geminiService.ts # Gemini AI integration โ€” structured horoscope output โ”œโ”€โ”€ index.html # Vite HTML entry point โ”œโ”€โ”€ package.json # Dependencies: React, Framer Motion, Lucide, Gemini SDK โ”œโ”€โ”€ vite.config.ts # Vite configuration โ”œโ”€โ”€ tsconfig.json # TypeScript configuration โ””โ”€โ”€ .env.example # Environment variable template (GEMINI_API_KEY)

๐Ÿค– Core AI Layer: geminiService.ts

This is the heart of the app โ€” the Gemini AI service that generates a structured horoscope for a given zodiac sign and language. It uses schema-enforced JSON output to guarantee consistent, typed responses.

๐Ÿ“„ src/services/geminiService.ts โ€” Structured Horoscope Generation
import { GoogleGenAI, Type, ThinkingLevel } from "@google/genai"; const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY || "" }); // Shape of the structured response returned by Gemini export interface HoroscopeData { date: string; reading: string; // ~100-word daily horoscope skyExplainer: string; // Plain-language planetary movement explanation snapshot: { // 3 key planets with positions + influences planet: string; position: string; influence: string; }[]; } export async function generateDailyHoroscope( sign: string, language: 'en' | 'zh' = 'en' ): Promise<HoroscopeData> { const today = new Date().toISOString().split('T')[0]; const langPrompt = language === 'zh' ? "in Mandarin Chinese (Simplified)" : "in English"; const response = await ai.models.generateContent({ model: "gemini-3-flash-preview", contents: `Generate a daily horoscope for ${sign} for the date ${today} ${langPrompt}. Include: 1. A personalized daily reading (about 100 words). 2. A "Sky Explainer" that identifies 2-3 real planetary movements happening today and explains in plain language why they affect ${sign}. 3. A "Sky Snapshot" โ€” list of 3 key planets, their current astrological position (e.g., "Mars in Leo"), and their brief influence. The tone should be grounded, credible, and atmospheric.`, config: { thinkingConfig: { thinkingLevel: ThinkingLevel.LOW }, // Speed over depth responseMimeType: "application/json", responseSchema: { // Enforce exact output shape type: Type.OBJECT, properties: { date: { type: Type.STRING }, reading: { type: Type.STRING }, skyExplainer: { type: Type.STRING }, snapshot: { type: Type.ARRAY, items: { type: Type.OBJECT, properties: { planet: { type: Type.STRING }, position: { type: Type.STRING }, influence: { type: Type.STRING } }, required: ["planet", "position", "influence"] } } }, required: ["date", "reading", "skyExplainer", "snapshot"] } } }); return JSON.parse(response.text); // Safe โ€” schema guarantees valid JSON }

Why this approach works: By passing a responseSchema to Gemini, the API guarantees the response will always conform to the declared shape. This eliminates defensive JSON parsing, removes the need for fallback logic, and ensures the TypeScript types match reality at runtime.

โš›๏ธ App State & Screen Management: App.tsx

The main app manages three screens (welcome โ†’ home โ†’ archive), user preferences, reading caching, and language toggling โ€” all via React state and localStorage.

๐Ÿ“„ src/App.tsx โ€” Core State & Caching Logic
// Screen and language types type Screen = 'welcome' | 'home' | 'archive'; type Language = 'en' | 'zh'; export default function App() { // Persist sign and language across sessions via localStorage const [userSign, setUserSign] = useState<string | null>( localStorage.getItem('skyread_sign') ); const [language, setLanguage] = useState<Language>( () => (localStorage.getItem('skyread_lang') as Language) || 'en' ); const [todayReading, setTodayReading] = useState<HoroscopeData | null>(null); const [archive, setArchive] = useState<HoroscopeData[]>(() => { const saved = localStorage.getItem('skyread_archive'); return saved ? JSON.parse(saved) : []; }); // Fetch a new reading โ€” skip if today's reading in this language is cached const fetchDailyReading = async (sign: string, lang: Language) => { const today = new Date().toISOString().split('T')[0]; // Detect language of cached reading via CJK Unicode range const isCachedInLang = todayReading?.date === today && ( lang === 'zh' ? /[\u4e00-\u9fa5]/.test(todayReading.reading) : !/[\u4e00-\u9fa5]/.test(todayReading.reading) ); if (isCachedInLang) return; // Already have today's reading in this language setLoading(true); try { const data = await generateDailyHoroscope(sign, lang); setTodayReading(data); // Add to archive, keep last 30 days, avoid duplicates const newArchive = [ data, ...archive.filter(item => item.date !== today || item.reading !== data.reading) ].slice(0, 30); setArchive(newArchive); localStorage.setItem('skyread_archive', JSON.stringify(newArchive)); } finally { setLoading(false); } }; // Toggle language and force a fresh reading in the new language const toggleLanguage = () => { const newLang = language === 'en' ? 'zh' : 'en'; setLanguage(newLang); localStorage.setItem('skyread_lang', newLang); setTodayReading(null); // Clears cache โ†’ triggers re-fetch in new language }; }

๐ŸŒŸ Animated Loading State

During AI generation, the app cycles through themed loading messages every 2.5 seconds to create an immersive "consulting the stars" experience rather than a generic spinner.

๐Ÿ“„ src/App.tsx โ€” Rotating Loading Messages
const TRANSLATIONS = { en: { loadingMessages: [ "Consulting the celestial charts...", "Tracing planetary alignments...", "Decoding cosmic vibrations...", "Mapping the current sky...", "Translating the language of stars...", "Gleaning insights from the void..." ] }, zh: { loadingMessages: [ "ๆญฃๅœจๆŸฅ้˜…ๅคฉไฝ“ๅ›พ...", "ๆญฃๅœจ่ฟฝ่ธช่กŒๆ˜ŸๆŽ’ๅˆ—...", "ๆญฃๅœจ่งฃ็ ๅฎ‡ๅฎ™ๅ…ฑๆŒฏ...", "ๆญฃๅœจ็ป˜ๅˆถๅฝ“ๅ‰ๆ˜Ÿ็ฉบ...", "ๆญฃๅœจ็ฟป่ฏ‘ๆ˜Ÿไน‹่ฏญ่จ€...", "ๆญฃๅœจไปŽ่™š็ฉบไธญ่Žทๅ–ๆดž่ง..." ] } }; // Cycle messages every 2.5s while loading โ€” stops when loading ends useEffect(() => { let interval: NodeJS.Timeout; if (loading) { let i = 0; interval = setInterval(() => { setLoadingMessage(t.loadingMessages[i % t.loadingMessages.length]); i++; }, 2500); } return () => clearInterval(interval); }, [loading, language]);

โš™๏ธ Technical Implementation Notes

Key Design Decisions