Google Spreadsheet 팝업 — onOpen 은 파일을 열 때 도는 예약 이름(단순 트리거)이라 메뉴를 거기서 붙인다.1

function showAlert() {
  const ui = SpreadsheetApp.getUi()
  ui.alert('팝업 창', '표시할 텍스트', ui.ButtonSet.OK)
}

// 메뉴에 추가
function onOpen() {
  SpreadsheetApp.getUi()
    .createMenu('팝업 메뉴')
    .addItem('팝업 띄우기', 'showAlert')
    .addToUi()
}

Footnotes

  1. Simple Triggers - Apps Script: “onOpen(e) runs when a user opens a spreadsheet, document, presentation, or form that the user has permission to edit.” / Class Ui: “Opens a dialog box in the user’s editor with the given title, message, and set of buttons.” ↩

#337

특수 기호 제거

function removeSpecialCharacters(input) {
  return input.replace(/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]+/g, '')
}

removeSpecialCharacters('Hello_World123! 안녕하세요?')
// "HelloWorld123 안녕하세요"
#336

조건부 렌더링을 한다고 했을때 예전에는 주로 B로 처리했던 것 같은데 디버깅 때문에 고생해서 그런지 생각이 바뀌었다.

function renderA() {
  return <Item isPacked={true} name="Space suit" />
}

function renderB() {
  return <>{isPacked ? <Item name="Space suit" /> : null}</>
}

react.dev 는 A 가 흔치 않고 B 가 더 흔하다고 적는다.1


조건부 렌더링을 보다 간결하게 표현하기 위해 If, Then, 그리고 Else 컴포넌트 개념을 차용하는 방법. 이 컴포넌트는 If에서 condition prop을 통해 조건을 받아들이고, 자식으로 Then과 Else를 받아 각각의 내용을 렌더링하도록 한다.

type Props = {
  /** 렌더링할 조건 */
  condition: boolean
  /** 자식 컴포넌트 (Then, Else 포함) */
  children: React.ReactNode
}

/**
 * If 컴포넌트는 조건부 렌더링을 위한 컴포넌트
 */
function If({ condition, children }: Props) {
  let thenChild = null
  let elseChild = null

  React.Children.forEach(children, (child) => {
    if (!React.isValidElement(child)) return
    if (child.type === Then) thenChild = child
    if (child.type === Else) elseChild = child
  })

  return condition ? thenChild : elseChild
}

/**
 * Then, Else 컴포넌트는 조건이 참 또는 거짓일 때 렌더링되는 콘텐츠를 포함
 */
const Then = ({ children }: React.PropsWithChildren) => <>{children}</>
const Else = ({ children }: React.PropsWithChildren) => <>{children}</>

Footnotes

  1. Conditional Rendering – React: “In practice, returning null from a component isn’t common because it might surprise a developer trying to render it. More often, you would conditionally include or exclude the component in the parent component’s JSX.” ↩

#335

MongoDB TTL 인덱스 (자동 문서 삭제)1

// 데이터 삽입
db.collection.insertOne({
  name: 'Temporary',
  expireAt: new Date('2024-11-30T00:00:00Z'),
})

// TTL 인덱스 생성 (expireAt 시간 기준 삭제)
db.collection.createIndex({ expireAt: 1 }, { expireAfterSeconds: 0 })
  • 60초 간격 백그라운드 체크로 삭제 (즉시 아님)2
  • expireAt은 Date 값이어야 함 — 아니면 만료되지 않음3
  • 활용: 세션 만료, 캐시 관리, 임시 데이터

SQLite는 TTL 미지원 → 트리거 또는 외부 스케줄러로 구현


Footnotes

  1. Expire Data from Collections by Setting TTL | MongoDB: “To expire documents at a specific clock time, create a TTL index on a field that holds values of BSON date type or an array of BSON date-typed objects, and specify an expireAfterSeconds value of 0.” ↩

  2. TTL Indexes | MongoDB: “The background task that removes expired documents runs every 60 seconds.” ↩

  3. TTL Indexes | MongoDB: “If the indexed field in a document doesn’t contain one or more date values, the document will not expire.” ↩

#334

SQLite 게시물 업데이트 내역 저장

CREATE TABLE posts (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  title TEXT NOT NULL,
  content TEXT NOT NULL,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE post_history (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  post_id INTEGER NOT NULL,
  title TEXT,
  content TEXT,
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (post_id) REFERENCES posts (id) ON DELETE CASCADE
);

-- 업데이트 전 내역 저장
INSERT INTO post_history (post_id, title, content, updated_at)
SELECT id, title, content, updated_at FROM posts WHERE id = 1;

-- 게시물 업데이트
UPDATE posts SET title = '새 제목', updated_at = CURRENT_TIMESTAMP WHERE id = 1;

posts : post_history = 1 : N 관계

#332

SQLite JSON 태그 빈도 정렬 — json_each 가 배열을 원소당 한 행으로 펼친다. 다시 묶을 때 순서는 집계 함수 안의 ORDER BY 로만 보장된다1

-- 태그별 빈도 계산
SELECT json_each.value AS tag, COUNT(*) AS frequency
FROM example, json_each(example.tags)
GROUP BY json_each.value
ORDER BY frequency DESC, tag ASC;

-- 빈도순 정렬된 태그를 JSON 배열로 (집계 안 ORDER BY — SQLite 3.44+)
SELECT json_group_array(tag ORDER BY frequency DESC, tag ASC) AS tags_by_frequency
FROM (
  SELECT json_each.value AS tag, COUNT(*) AS frequency
  FROM example, json_each(example.tags)
  GROUP BY json_each.value
);

Footnotes

  1. Built-in Aggregate Functions - SQLite: “If no ORDER BY clause is specified, the inputs to the aggregate occur in an arbitrary order that might change from one invocation to the next.” · 3.44.0 릴리스 노트: “Aggregate functions can now include an ORDER BY clause after their last parameter.” ↩

#331

빈 PDF 파일이 필요해서 찾아본 방법들

import { Canvas } from 'skia-canvas'
import { writeFile } from 'node:fs/promises'

/**
 * A4 크기의 빈 PDF 파일을 생성합니다.
 *
 * @param {string} filename - 생성할 PDF 파일의 이름입니다.
 * @returns {Promise<void>}
 */
async function createEmptyPDF(filename) {
  // A4 크기 (595x842 포인트)
  const width = 595
  const height = 842

  // 캔버스를 생성합니다
  const canvas = new Canvas(width, height)
  const ctx = canvas.getContext('2d')

  // 아무 내용도 그리지 않고 현재 상태를 저장합니다
  ctx.save()

  // 캔버스를 PDF 버퍼로 변환하여 저장합니다
  const buffer = await canvas.toBuffer('pdf')
  await writeFile(filename, buffer)
  console.log(`${filename} 파일이 생성되었습니다!`)
}

createEmptyPDF('empty_skia.pdf')

skia-canvas 는 형식을 캔버스가 아니라 내보낼 때 정한다 — createCanvas(w, h, 'pdf') 는 node-canvas 의 API 라 여기엔 없다1.


# Ghostscript를 사용한 빈 PDF 생성
gs -q -dNOPAUSE -dBATCH -sDEVICE=pdfwrite -sOutputFile=empty.pdf -c "<< /PageSize [595 842] >> setpagedevice showpage"

# ImageMagick의 `convert` 명령어로 빈 PDF 생성
convert xc:white -page A4 empty.pdf

Footnotes

  1. Canvas | skia-canvas: “Node Buffer objects containing various image formats can be created by passing either a format string like "svg" or a mime-type like "image/svg+xml".” ↩

#330

Apps Script로 캘린더 예약을 만들 때 쓰는 건 결국 API 두 개다.

Calendar.Freebusy.query로 바쁜 일정을 받아 근무 요일·근무 시간과 겹치는 슬롯을 빼면 예약 가능한 슬롯이 남는다. 예약은 CalendarApp.getCalendarById로 이벤트를 만들고 sendInvites: true로 초대 메일까지 보낸다1.

Freebusy는 슬롯을 계산할 때와 실제로 예약할 때 두 번 조회한다 — 그 사이에 일정이 생길 수 있으니.2


Footnotes

  1. Class Calendar · createEvent(title, startTime, endTime, options) | Apps Script: “Whether to send invitation emails (default: false).” — 기본값이 꺼져 있다 ↩

  2. someday backend/src/app.ts — 슬롯 계산(fetchAvailability, L51)과 별도로 bookTimeslot 이 같은 구간을 다시 조회해 busy 가 있으면 Timeslot not available 을 던진다 ↩

#329

AuthContext 테스트 패턴

// localStorage 함수 테스트
vi.spyOn(Storage.prototype, 'getItem').mockImplementation((key) => {
  if (key === 'auth_token') return 'test_token'
  return null
})

// AuthProvider 상태 테스트
const TestComponent = () => {
  const { token, login, logout } = useAuth()

  return (
    <div>
      <p data-testid="token">{token || 'null'}</p>
      <button onClick={() => login('test_token')}>Login</button>
      <button onClick={logout}>Logout</button>
    </div>
  )
}

// useAuth 훅 테스트
it('AuthProvider 외부에서 호출하면 에러', () => {
  expect(() => renderHook(() => useAuth())).toThrowError()
})

it('AuthProvider 내부에서 정상 동작', () => {
  const wrapper = ({ children }) => <AuthProvider>{children}</AuthProvider>
  const { result } = renderHook(() => useAuth(), { wrapper })
  expect(result.current).toHaveProperty('token', null)
})
#328

타이머를 제어하기 위해 vi.useFakeTimers1와 vi.advanceTimersByTime2을 사용하기.

describe('Countdown 컴포넌트', () => {
  beforeEach(() => {
    vi.useFakeTimers()
  })

  afterEach(() => {
    vi.useRealTimers()
  })

  test('타이머가 동작하며 시간을 업데이트한다.', () => {
    const endTime = new Date(Date.now() + 60000).toISOString()
    const renderChild = vi.fn(({ targetRef }) => (
      <div ref={targetRef} data-testid="countdown" />
    ))

    render(<Countdown end_at={endTime}>{renderChild}</Countdown>)

    expect(screen.getByTestId('countdown')).toBeEmptyDOMElement()

    act(() => {
      vi.advanceTimersByTime(10000)
    })

    expect(screen.getByTestId('countdown')).toHaveTextContent('00:00:50')

    act(() => {
      vi.advanceTimersByTime(50000)
    })

    expect(screen.getByTestId('countdown')).toHaveTextContent('00:00:00')
  })
})

Footnotes

  1. vi.useFakeTimers | Vitest: “To enable mocking timers, you need to call this method. It will wrap all further calls to timers (such as setTimeout, setInterval, clearTimeout, clearInterval, setImmediate, clearImmediate, and Date) until vi.useRealTimers() is called.” ↩

  2. vi.advanceTimersByTime | Vitest: “This method will invoke every initiated timer until the specified number of milliseconds is passed or the queue is empty - whatever comes first.” ↩

#327
33 중 14페이지