Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
224 changes: 218 additions & 6 deletions lib/offline-storage.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { interviewTemplates, type InterviewTemplate } from "@/data/interview-templates"
import { mockQuestions } from "@/data/mock-questions"
import type { InterviewSession, InterviewQuestion } from "@/types/interview"
import type { InterviewSession, InterviewQuestion, UserQuestionSet, QuestionBankSyncState } from "@/types/interview"

const STORAGE_KEYS = {
INTERVIEW_SESSIONS: 'interview-sessions',
CACHED_QUESTIONS: 'cached-questions',
TEMPLATES: 'interview-templates',
USER_PREFERENCES: 'user-preferences',
USER_QUESTION_SETS: 'user-question-sets',
USER_QUESTION_SET_SYNC_STATE: 'user-question-set-sync-state',
} as const

export interface StoredSession {
Expand All @@ -30,6 +32,18 @@ export interface UserPreferences {
theme: 'light' | 'dark' | 'system'
}

export type CachedUserQuestionSet = UserQuestionSet

type UserQuestionSetSyncState = QuestionBankSyncState

type UserQuestionSetLike = Omit<UserQuestionSet, 'tags' | 'questions' | 'createdAt' | 'updatedAt' | 'difficulty'> & {
tags?: string[]
questions?: InterviewQuestion[]
createdAt?: string
updatedAt?: string
difficulty?: UserQuestionSet['difficulty']
}

export class OfflineStorage {
private static instance: OfflineStorage

Expand Down Expand Up @@ -61,6 +75,66 @@ export class OfflineStorage {
}
}

private normalizeInterviewQuestion(
question: InterviewQuestion,
setId?: string,
fallbackTags: string[] = []
): InterviewQuestion {
const followUpValue = question.followUp
const normalizedFollowUp = Array.isArray(followUpValue)
? followUpValue.filter((item): item is string => typeof item === 'string')
: typeof followUpValue === 'string'
? [followUpValue]
: []

const tagsValue = (Array.isArray(question.tags) ? question.tags : fallbackTags)
.filter((tag): tag is string => typeof tag === 'string')

return {
...question,
followUp: normalizedFollowUp.length > 0 ? normalizedFollowUp : undefined,
origin: question.origin ?? (setId ? 'user' : 'default'),
setId: setId ?? question.setId,
tags: tagsValue
}
}

private normalizeUserQuestionSet(set: UserQuestionSetLike): CachedUserQuestionSet {
const tags = Array.isArray(set.tags)
? set.tags.filter((tag): tag is string => typeof tag === 'string')
: []

const createdAt = set.createdAt ?? new Date().toISOString()
const updatedAt = set.updatedAt ?? createdAt
const allowedDifficulties: Array<UserQuestionSet['difficulty']> = ['easy', 'medium', 'hard', 'mixed']
const difficulty = set.difficulty && allowedDifficulties.includes(set.difficulty)
? set.difficulty
: 'medium'

const questions = (set.questions ?? []).map(question =>
this.normalizeInterviewQuestion(question, set.id, tags)
)

return {
id: set.id,
title: set.title,
industry: set.industry ?? null,
tags,
difficulty,
createdAt,
updatedAt,
questions
}
}

private normalizeSyncState(state?: QuestionBankSyncState | null): UserQuestionSetSyncState {
if (!state || !state.lastSyncedAt) {
return { lastSyncedAt: null }
}

return { lastSyncedAt: state.lastSyncedAt }
}

// Interview Sessions
public saveSession(session: StoredSession): void {
const sessions = this.getAllSessions()
Expand Down Expand Up @@ -104,28 +178,144 @@ export class OfflineStorage {
public cacheQuestions(questions: InterviewQuestion[]): void {
const existing = this.getCachedQuestions()
const merged = [...existing]

questions.forEach(newQuestion => {

const normalizedIncoming = questions.map(question =>
this.normalizeInterviewQuestion(question, question.setId)
)

normalizedIncoming.forEach(newQuestion => {
const existingIndex = merged.findIndex(q => q.id === newQuestion.id)
if (existingIndex >= 0) {
merged[existingIndex] = newQuestion
} else {
merged.push(newQuestion)
}
})

this.setItem(STORAGE_KEYS.CACHED_QUESTIONS, merged)
}

public getCachedQuestions(): InterviewQuestion[] {
const cached = this.getItem<InterviewQuestion[]>(STORAGE_KEYS.CACHED_QUESTIONS)
return cached || mockQuestions
if (cached && cached.length > 0) {
return cached.map(question => this.normalizeInterviewQuestion(question, question.setId))
}

return mockQuestions.map(question => this.normalizeInterviewQuestion(question))
}

public addQuestionsToCache(questions: InterviewQuestion[]): void {
this.cacheQuestions(questions)
}

// User Question Sets
public getCachedUserQuestionSets(): CachedUserQuestionSet[] {
const cached = this.getItem<CachedUserQuestionSet[]>(STORAGE_KEYS.USER_QUESTION_SETS)
if (!cached) {
return []
}

return cached.map(set => this.normalizeUserQuestionSet(set))
}

public syncUserQuestionSets(sets: UserQuestionSetLike[], syncedAt?: string): void {
const normalized = sets.map(set => this.normalizeUserQuestionSet(set))
this.setItem(STORAGE_KEYS.USER_QUESTION_SETS, normalized)
this.markUserQuestionSetsSynced(syncedAt)
}

public upsertUserQuestionSets(sets: UserQuestionSetLike[], syncedAt?: string): void {
if (sets.length === 0) {
this.markUserQuestionSetsSynced(syncedAt)
return
}

const existing = this.getCachedUserQuestionSets()
const byId = new Map(existing.map(set => [set.id, set]))

sets.forEach(set => {
byId.set(set.id, this.normalizeUserQuestionSet(set))
})

const merged = Array.from(byId.values()).sort((a, b) => {
const updatedA = new Date(a.updatedAt ?? '').getTime() || 0
const updatedB = new Date(b.updatedAt ?? '').getTime() || 0
return updatedB - updatedA
})

this.setItem(STORAGE_KEYS.USER_QUESTION_SETS, merged)
this.markUserQuestionSetsSynced(syncedAt)
}

public getQuestionsFromUserSetIds(setIds: string[]): InterviewQuestion[] {
if (!Array.isArray(setIds) || setIds.length === 0) {
return []
}

const uniqueIds = setIds.filter((id, index) => setIds.indexOf(id) === index)
const sets = this.getCachedUserQuestionSets()
const setMap = new Map(sets.map(set => [set.id, set]))

const aggregated: InterviewQuestion[] = []

uniqueIds.forEach(id => {
const set = setMap.get(id)
if (!set) {
return
}

set.questions.forEach(question => {
aggregated.push(this.normalizeInterviewQuestion(question, set.id, set.tags))
})
})

const seen = new Set<string>()
const deduped: InterviewQuestion[] = []

aggregated.forEach(question => {
if (seen.has(question.id)) {
return
}

seen.add(question.id)
deduped.push(question)
})

return deduped
}

public removeUserQuestionSet(id: string): void {
const sets = this.getCachedUserQuestionSets().filter(set => set.id !== id)
this.setItem(STORAGE_KEYS.USER_QUESTION_SETS, sets)
}

public clearUserQuestionSets(): void {
this.setItem(STORAGE_KEYS.USER_QUESTION_SETS, [])
this.setItem(STORAGE_KEYS.USER_QUESTION_SET_SYNC_STATE, this.normalizeSyncState(null))
}

public markUserQuestionSetsSynced(syncedAt?: string | null): void {
if (syncedAt === null) {
this.setItem(
STORAGE_KEYS.USER_QUESTION_SET_SYNC_STATE,
this.normalizeSyncState({ lastSyncedAt: null })
)
return
}

const timestamp = syncedAt ?? new Date().toISOString()

this.setItem(
STORAGE_KEYS.USER_QUESTION_SET_SYNC_STATE,
this.normalizeSyncState({ lastSyncedAt: timestamp })
)
}

public getUserQuestionSetSyncState(): UserQuestionSetSyncState {
const state = this.getItem<QuestionBankSyncState>(STORAGE_KEYS.USER_QUESTION_SET_SYNC_STATE)
return this.normalizeSyncState(state)
}

// User Preferences
public savePreferences(preferences: Partial<UserPreferences>): void {
const existing = this.getPreferences()
Expand Down Expand Up @@ -177,6 +367,8 @@ export class OfflineStorage {
preferences: this.getPreferences(),
templates: this.getCachedTemplates(),
questions: this.getCachedQuestions(),
userQuestionSets: this.getCachedUserQuestionSets(),
userQuestionSetSyncState: this.getUserQuestionSetSyncState(),
exportedAt: new Date().toISOString(),
}

Expand All @@ -202,6 +394,15 @@ export class OfflineStorage {
if (data.questions) {
this.setItem(STORAGE_KEYS.CACHED_QUESTIONS, data.questions)
}

if (Array.isArray(data.userQuestionSets)) {
this.syncUserQuestionSets(data.userQuestionSets)
}

if (data.userQuestionSetSyncState) {
const normalizedState = this.normalizeSyncState(data.userQuestionSetSyncState)
this.setItem(STORAGE_KEYS.USER_QUESTION_SET_SYNC_STATE, normalizedState)
}

return true
} catch (error) {
Expand All @@ -216,11 +417,22 @@ export class OfflineStorage {
if (!this.getItem(STORAGE_KEYS.TEMPLATES)) {
this.cacheTemplates()
}

// Cache default questions if not already cached
if (!this.getItem(STORAGE_KEYS.CACHED_QUESTIONS)) {
this.addQuestionsToCache(mockQuestions)
}

if (!this.getItem(STORAGE_KEYS.USER_QUESTION_SETS)) {
this.setItem(STORAGE_KEYS.USER_QUESTION_SETS, [])
}

if (!this.getItem(STORAGE_KEYS.USER_QUESTION_SET_SYNC_STATE)) {
this.setItem(
STORAGE_KEYS.USER_QUESTION_SET_SYNC_STATE,
this.normalizeSyncState(null)
)
}
}
}

Expand Down
Loading